vestigia

Detect where execution depends on secrets.
git clone git://git.ppad.tech/vestigia.git
Log | Files | Refs | README | LICENSE

commit 3b28eb354393ee5d36e747b3489f70591b438764
parent d46bfe999c7d30bc3eb120bbab1f68471653a533
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat, 17 Oct 2026 20:11:35 +0400

vestigia: differential leakage traces under QEMU, and the Penumbra spike

Diffstat:
Mapp/Main.hs | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aetc/probe/diffprobe.c | 32++++++++++++++++++++++++++++++++
Aetc/probe/diffprobe.nix | 23+++++++++++++++++++++++
Metc/spike/penumbra/default.nix | 13+++++++++++++
Metc/spike/penumbra/harness.c | 33+++++++++++++++++++--------------
Mflake.nix | 59++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Alib/Vestigia/Diff.hs | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/Vestigia/Symbol.hs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplugin/vestigia-qemu.c | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/Main.hs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mvestigia.cabal | 6+++++-
11 files changed, 867 insertions(+), 19 deletions(-)

diff --git a/app/Main.hs b/app/Main.hs @@ -27,14 +27,23 @@ import System.IO (hClose, hPutStrLn, openTempFile, stderr) import System.Process (proc, waitForProcess, withCreateProcess) import Paths_vestigia (version) +import Vestigia.Diff + (diffDumps, divergenceJson, divergenceSummary, dvOrigins, inObject, + inOffset, readDump, restrictTo) import Vestigia.Json (encode) import Vestigia.Memcheck (readMemcheck) +import Vestigia.Symbol (symbolize) import Vestigia.Trace (trace, traceJson, traceSummary) data Opts = Opts { oValgrind :: !(Maybe FilePath) , oXml :: !(Maybe FilePath) , oOut :: !(Maybe FilePath) + , oQemu :: !(Maybe FilePath) + , oPlugin :: !(Maybe FilePath) + , oDump :: !(Maybe FilePath) + , oAddr2line :: !(Maybe FilePath) + , oObjects :: ![T.Text] } main :: IO () @@ -49,6 +58,14 @@ main = do Right (o, [ xml ]) -> readXml o xml Right _ -> usage "read: one XML file expected" Left e -> usage e + ("record" : rest) -> case options rest of + Right (o, prog : progArgs) -> record o prog progArgs + Right (_, []) -> usage "record: no program given" + Left e -> usage e + ("diff" : rest) -> case options rest of + Right (o, dumps@(_ : _ : _)) -> diff o dumps + Right _ -> usage "diff: two or more dumps expected" + Left e -> usage e [ "--version" ] -> putStrLn (showVersion version) _ -> usage "expected a command" @@ -60,23 +77,42 @@ usage e = do , " vestigia run [--valgrind PATH] [--xml FILE] [--out FILE] \ \-- PROGRAM [ARG...]" , " vestigia read [--out FILE] FILE.xml" + , " vestigia record [--qemu PATH] [--plugin PATH] --dump FILE \ + \-- PROGRAM [ARG...]" + , " vestigia diff [--out FILE] [--addr2line PATH] \ + \[--object TEXT]... DUMP DUMP..." , "" , "run executes PROGRAM under memcheck, which reports every" , "branch and address that depended on bytes the program marked" , "secret (vestigia.h), and writes the report (vestigia/trace-v1)." , "valgrind is --valgrind, else $VESTIGIA_VALGRIND, else on PATH." + , "" + , "record executes PROGRAM under QEMU with vestigia's plugin and" + , "dumps its leakage trace per instruction; diff compares dumps" + , "of runs that differ only in a secret and reports where they" + , "diverge (vestigia/diff-v1), naming sources with addr2line." + , "--object keeps origins in objects whose path contains TEXT." + , "qemu, the plugin, and addr2line default to $VESTIGIA_QEMU," + , "$VESTIGIA_PLUGIN, and $VESTIGIA_ADDR2LINE." ] exitWith (ExitFailure 2) -- options before the program; "--" ends them options :: [String] -> Either String (Opts, [String]) -options = go (Opts Nothing Nothing Nothing) +options = go (Opts Nothing Nothing Nothing Nothing Nothing Nothing + Nothing []) where go o xs = case xs of ("--" : rest) -> Right (o, rest) ("--valgrind" : p : rest) -> go o { oValgrind = Just p } rest ("--xml" : p : rest) -> go o { oXml = Just p } rest ("--out" : p : rest) -> go o { oOut = Just p } rest + ("--qemu" : p : rest) -> go o { oQemu = Just p } rest + ("--plugin" : p : rest) -> go o { oPlugin = Just p } rest + ("--dump" : p : rest) -> go o { oDump = Just p } rest + ("--addr2line" : p : rest) -> go o { oAddr2line = Just p } rest + ("--object" : t : rest) -> + go o { oObjects = oObjects o <> [ T.pack t ] } rest (x : _) | take 2 x == "--" -> Left ("unknown option: " <> x) _ -> Right (o, xs) @@ -141,3 +177,47 @@ report o exit xml = do (traceJson (T.pack (showVersion version)) exit tr) maybe (TIO.putStr doc) (\p -> TIO.writeFile p doc) (oOut o) TIO.hPutStr stderr (traceSummary tr) + +-- a tool named by its option, else its environment variable, else +-- the given default +tool :: Maybe FilePath -> String -> String -> IO FilePath +tool opt var dflt = do + env <- lookupEnv var + pure (maybe (maybe dflt id env) id opt) + +record :: Opts -> FilePath -> [String] -> IO () +record o prog progArgs = do + dump <- maybe (usage "record: --dump FILE is needed") pure (oDump o) + qemu <- tool (oQemu o) "VESTIGIA_QEMU" "qemu-x86_64" + plugin <- tool (oPlugin o) "VESTIGIA_PLUGIN" "libvestigia-qemu.so" + r <- try (withCreateProcess + (proc qemu ([ "-plugin", plugin <> ",out=" <> dump, prog ] + <> progArgs)) + (\_ _ _ ph -> waitForProcess ph)) + case r of + Left e -> do + hPutStrLn stderr ("vestigia: cannot run " <> qemu <> ": " + <> show (e :: IOException)) + exitWith (ExitFailure 2) + Right c -> exitWith c + +diff :: Opts -> [FilePath] -> IO () +diff o paths = do + addr2line <- tool (oAddr2line o) "VESTIGIA_ADDR2LINE" "addr2line" + dumps <- traverse readOne paths + let dv = restrictTo (oObjects o) (diffDumps dumps) + places <- symbolize addr2line + [ (obj, inOffset i) | (_, i) <- dvOrigins dv + , Just obj <- [ inObject i ] ] + let doc = encode + (divergenceJson (T.pack (showVersion version)) places dv) + maybe (TIO.putStr doc) (\p -> TIO.writeFile p doc) (oOut o) + TIO.hPutStr stderr (divergenceSummary places dv) + where + readOne p = do + bytes <- BS.readFile p + case readDump (TE.decodeUtf8With TEE.lenientDecode bytes) of + Left e -> do + hPutStrLn stderr ("vestigia: " <> p <> ": " <> e) + exitWith (ExitFailure 2) + Right d -> pure d diff --git a/etc/probe/diffprobe.c b/etc/probe/diffprobe.c @@ -0,0 +1,32 @@ +/* A secret chosen by the first argument (same width for every + * secret, so the stack lays out the same) reaching a branch and a + * table index, and a constant-time select that touches neither. */ +#include <stdio.h> +#include <stdlib.h> + +static unsigned char table[256]; + +__attribute__((noinline)) int branchy(unsigned s) { + int r = 0; + for (unsigned i = 0; i < (s & 7); i++) r += i; + return r; +} + +__attribute__((noinline)) int lookup(unsigned s) { + return table[s & 0xff]; +} + +__attribute__((noinline)) unsigned ct(unsigned s, unsigned p) { + unsigned m = 0u - (p & 1u); + return (s & m) | (s ^ m); +} + +int main(int argc, char **argv) { + unsigned s = (unsigned)strtoul(argc > 1 ? argv[1] : "00", NULL, 16); + for (int i = 0; i < 256; i++) table[i] = (unsigned char)i; + int a = branchy(s); + int b = lookup(s); + unsigned c = ct(s, 1); + printf("%d %d %u\n", a, b, c); + return 0; +} diff --git a/etc/probe/diffprobe.nix b/etc/probe/diffprobe.nix @@ -0,0 +1,23 @@ +let + f = builtins.getFlake "/Users/jtobin/src/ppad/vestigia"; + pkgs = import f.inputs.nixpkgs { system = "x86_64-linux"; }; + plugin = pkgs.runCommandCC "vestigia-qemu-plugin" { + nativeBuildInputs = [ pkgs.pkg-config ]; + buildInputs = [ pkgs.glib ]; + } '' + mkdir -p $out/lib + cc -O2 -shared -fPIC -Wall -Werror \ + $(pkg-config --cflags glib-2.0) -I${pkgs.qemu}/include \ + -o $out/lib/libvestigia-qemu.so ${../../plugin/vestigia-qemu.c} + ''; +in pkgs.runCommandCC "vestigia-diffprobe" { } '' + mkdir -p $out + cc -O2 -g -o probe ${./diffprobe.c} + for s in 03 05; do + ${pkgs.qemu}/bin/qemu-x86_64 \ + -plugin ${plugin}/lib/libvestigia-qemu.so,out=$out/dump-$s \ + ./probe $s > $out/stdout-$s + done + objdump -d probe > $out/probe.dis + cp probe $out/ +'' diff --git a/etc/spike/penumbra/default.nix b/etc/spike/penumbra/default.nix @@ -39,10 +39,23 @@ let }; roots = [ "public_key" "key_agreement" "spend_auth_sign" ]; + + # three processes of two calls each, differing only in the secrets + seeds = [ "0000000000000001" "0000000000000002" "0000000000000003" ]; + diffOf = build: lib: root: vestigia.lib.${system}.diffOf { + name = "penumbra-${build}-${root}"; + harness = "${harnessFor lib}/bin/harness"; + runs = map (s: [ root "2" s ]) seeds; + objects = [ "libpenumbra_audit" ]; + }; in { inherit audited lines; + diffs = pkgs.linkFarm "penumbra-diffs" ( + map (r: { name = "lines/${r}"; path = diffOf "lines" lines r; }) + roots); + traces = pkgs.linkFarm "penumbra-traces" ( map (r: { name = "audited/${r}"; path = traceOf "audited" audited r; }) roots diff --git a/etc/spike/penumbra/harness.c b/etc/spike/penumbra/harness.c @@ -3,8 +3,11 @@ * fresh random secrets, marked secret before the call, with the * outputs Penumbra reveals marked public after it. * - * usage: harness ROOT [RUNS] + * usage: harness ROOT [RUNS [SEED]] * ROOT is public_key, key_agreement, or spend_auth_sign + * SEED (16 hex digits) seeds the secrets alone; public inputs come + * from a fixed stream, so runs that differ in SEED differ only in + * their secrets. */ #include <stdint.h> #include <stdio.h> @@ -17,29 +20,30 @@ void penumbra_audit_public_key(uint8_t *ws); void penumbra_audit_key_agreement(uint8_t *ws); void penumbra_audit_spend_auth_sign(uint8_t *ws); -static uint64_t state = 0x9e3779b97f4a7c15ULL; +static uint64_t secrets = 0x9e3779b97f4a7c15ULL; +static uint64_t publics = 0x0123456789abcdefULL; -static uint64_t splitmix(void) { - uint64_t z = (state += 0x9e3779b97f4a7c15ULL); +static uint64_t splitmix(uint64_t *state) { + uint64_t z = (*state += 0x9e3779b97f4a7c15ULL); z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9ULL; z = (z ^ (z >> 27)) * 0x94d049bb133111ebULL; return z ^ (z >> 31); } -static void fill(uint8_t *p, size_t n) { - for (size_t i = 0; i < n; i++) p[i] = (uint8_t)splitmix(); +static void fill(uint64_t *state, uint8_t *p, size_t n) { + for (size_t i = 0; i < n; i++) p[i] = (uint8_t)splitmix(state); } /* a canonical scalar: random little-endian bytes with the top byte * cleared, below the decaf377 scalar modulus */ -static void scalar(uint8_t *p) { - fill(p, 32); +static void scalar(uint64_t *state, uint8_t *p) { + fill(state, p, 32); p[31] = 0; } static void public_key(void) { uint8_t ws[64]; - scalar(ws); + scalar(&secrets, ws); vestigia_secret(ws, 32); penumbra_audit_public_key(ws); vestigia_public(ws + 32, 32); @@ -48,9 +52,9 @@ static void public_key(void) { static void key_agreement(void) { uint8_t peer[64]; uint8_t ws[96]; - scalar(peer); + scalar(&publics, peer); penumbra_audit_public_key(peer); - scalar(ws); + scalar(&secrets, ws); memcpy(ws + 32, peer + 32, 32); vestigia_secret(ws, 32); penumbra_audit_key_agreement(ws); @@ -58,9 +62,9 @@ static void key_agreement(void) { static void spend_auth_sign(void) { uint8_t ws[208]; - scalar(ws); - fill(ws + 32, 80); - fill(ws + 112, 32); + scalar(&secrets, ws); + fill(&secrets, ws + 32, 80); + fill(&publics, ws + 112, 32); /* the key and the signing randomness are secret; the message is * public */ vestigia_secret(ws, 112); @@ -74,6 +78,7 @@ int main(int argc, char **argv) { return 2; } int runs = argc > 2 ? atoi(argv[2]) : 4; + if (argc > 3) secrets = strtoull(argv[3], NULL, 16); void (*root)(void) = NULL; if (strcmp(argv[1], "public_key") == 0) root = public_key; else if (strcmp(argv[1], "key_agreement") == 0) root = key_agreement; diff --git a/flake.nix b/flake.nix @@ -26,6 +26,25 @@ linux = pkgs.stdenv.hostPlatform.isLinux; valgrind = pkgs.valgrind; + # the QEMU plugin recording a run's leakage trace per + # instruction, and the QEMU that runs a harness of this + # system's ISA under it + qemu = pkgs.qemu; + qemuFor = { + x86_64 = "${qemu}/bin/qemu-x86_64"; + aarch64 = "${qemu}/bin/qemu-aarch64"; + }; + plugin = pkgs.runCommandCC "vestigia-qemu-plugin" { + nativeBuildInputs = [ pkgs.pkg-config ]; + buildInputs = [ pkgs.glib ]; + } '' + mkdir -p $out/lib + cc -O2 -shared -fPIC -Wall -Werror \ + $(pkg-config --cflags glib-2.0) -I${qemu}/include \ + -o $out/lib/libvestigia-qemu.so ${./plugin/vestigia-qemu.c} + ''; + isa = builtins.head (lib.splitString "-" system); + # the harness-side header; a harness build also needs # valgrind's own (valgrindDev) include = pkgs.runCommand "vestigia-include" { } '' @@ -39,8 +58,14 @@ } '' mkdir -p $out/bin makeWrapper ${hpkgs.vestigia}/bin/vestigia $out/bin/vestigia \ - ${lib.optionalString linux - "--set-default VESTIGIA_VALGRIND ${valgrind}/bin/valgrind"} + ${lib.optionalString linux (lib.concatStringsSep " " [ + "--set-default VESTIGIA_VALGRIND ${valgrind}/bin/valgrind" + "--set-default VESTIGIA_QEMU ${qemuFor.${isa}}" + "--set-default VESTIGIA_PLUGIN" + "${plugin}/lib/libvestigia-qemu.so" + "--set-default VESTIGIA_ADDR2LINE" + "${pkgs.binutils-unwrapped}/bin/addr2line" + ])} ln -s ${include}/include $out/include ''; @@ -51,6 +76,7 @@ traceOf = { name, harness, args ? [ ] }: pkgs.runCommand "vestigia-trace-${name}" { } '' mkdir -p $out + start=$(date +%s%N) set +e ${vestigia}/bin/vestigia run \ --xml $out/memcheck.xml --out $out/trace.json \ @@ -58,18 +84,45 @@ > $out/stdout 2> $out/stderr echo $? > $out/status set -e + echo $(( ($(date +%s%N) - start) / 1000000 )) > $out/milliseconds test -s $out/trace.json || { cat $out/stderr >&2 exit 1 } ''; + + # runs of one harness that differ only in a secret, each + # recorded under QEMU, and where they diverge: the dumps, and + # the report. `runs` is a list of argument lists; `objects` + # keeps origins in objects whose path contains one of them. + # A run's own failure leaves its status; a dump vestigia + # cannot read fails the build. + diffOf = { name, harness, runs, objects ? [ ] }: + pkgs.runCommand "vestigia-diff-${name}" { } '' + mkdir -p $out + start=$(date +%s%N) + ${lib.concatImapStrings (k: args: '' + set +e + ${vestigia}/bin/vestigia record --dump $out/dump-${toString k} \ + -- ${harness} ${lib.escapeShellArgs args} \ + > $out/stdout-${toString k} 2> $out/stderr-${toString k} + echo $? > $out/status-${toString k} + set -e + '') runs} + ${vestigia}/bin/vestigia diff --out $out/diff.json \ + ${lib.concatMapStrings (o: "--object ${lib.escapeShellArg o} ") + objects} \ + $out/dump-* 2> $out/summary + echo $(( ($(date +%s%N) - start) / 1000000 )) > $out/milliseconds + ''; in { packages.default = vestigia; packages.include = include; + packages.plugin = plugin; lib = { - inherit traceOf; + inherit traceOf diffOf; valgrindDev = valgrind.dev; }; diff --git a/lib/Vestigia/Diff.hs b/lib/Vestigia/Diff.hs @@ -0,0 +1,253 @@ +{-# OPTIONS_HADDOCK prune #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | +-- Module: Vestigia.Diff +-- Copyright: (c) 2026 Jared Tobin +-- License: MIT +-- Maintainer: Jared Tobin <jared@ppad.tech> +-- +-- Where runs that differ only in a secret differ. Each run is a +-- leakage trace summarised per instruction (the QEMU plugin's +-- dump): how often each instruction ran, how often it transferred +-- to each target, and a fingerprint of the addresses it touched. +-- +-- A branch whose outcome did not depend on the secret takes each +-- of its transfers in the same proportion however often it runs; a +-- branch whose outcomes differ in proportion between runs had its +-- own condition depend on the secret, and is a `control` origin +-- (a loop whose trip count the secret sets is one, though it also +-- runs a different number of times). An instruction run equally +-- often in every run whose addresses differ computed one from the +-- secret: an `address` origin. Every other instruction run a +-- different number of times sits where control had already +-- diverged: it is downstream, and is counted rather than listed. +-- +-- Unlike memcheck's taint, this sees what control flow does to +-- data as well as what data does: a value chosen by a secret +-- branch and then used as an index differs between runs, though +-- no undefined bit ever reached it. It sees only what the chosen +-- secrets actually varied. + +module Vestigia.Diff ( + Insn(..) + , Dump(..) + , readDump + , Origin(..) + , Divergence(..) + , diffDumps + , restrictTo + , divergenceJson + , divergenceSummary + ) where + +import qualified Data.List as L +import qualified Data.Map.Strict as M +import qualified Data.Set as S +import qualified Data.Text as T +import Numeric (readHex, showHex) + +import Vestigia.Json +import Vestigia.Symbol (Place(..)) + +-- | One instruction of a run. +data Insn = Insn { + inPc :: !Integer + , inCount :: !Integer + , inAccesses :: !Integer + , inFingerprint :: !T.Text + , inObject :: !(Maybe T.Text) + , inOffset :: !Integer + } deriving (Eq, Show) + +-- | One run: its instructions by address, and its transfers by +-- source, each a count per target. +data Dump = Dump { + dpInsns :: !(M.Map Integer Insn) + , dpTransfers :: !(M.Map Integer (M.Map Integer Integer)) + } deriving (Eq, Show) + +-- | Read a plugin dump. +readDump :: T.Text -> Either String Dump +readDump t = go (T.lines t) M.empty M.empty + where + go ls is es = case ls of + [] -> pure (Dump is es) + (l : rest) -> case T.words l of + [ "I", pc, n, a, fp, obj, off ] -> do + i <- Insn <$> unhex pc <*> unhex n <*> unhex a <*> pure fp + <*> pure (if obj == "-" then Nothing else Just obj) + <*> unhex off + go rest (M.insert (inPc i) i is) es + [ "E", from, to, n ] -> do + f <- unhex from + k <- unhex to + c <- unhex n + go rest is (M.insertWith M.union f (M.singleton k c) es) + [] -> go rest is es + _ -> Left ("an unreadable dump line: " <> T.unpack l) + unhex x = case readHex (T.unpack x) of + [ (v, "") ] -> pure v + _ -> Left ("not hexadecimal: " <> T.unpack x) + +-- | How an origin depended on the secret. +data Origin = + ControlOrigin + | AddressOrigin + deriving (Eq, Ord, Show) + +-- | Where the runs differ: the origins, each an instruction run +-- equally often everywhere, and how many instructions ran a +-- different number of times. +data Divergence = Divergence { + dvOrigins :: ![(Origin, Insn)] + , dvDownstream :: ![Insn] + , dvRuns :: !Int + } deriving (Eq, Show) + +-- | Compare runs. Every run is set against every other, so an +-- origin is any instruction at which some two runs differ. +diffDumps :: [Dump] -> Divergence +diffDumps ds = Divergence { + dvOrigins = L.sortOn (inPc . snd) (controls <> addresses) + , dvDownstream = [ i | pc <- S.toAscList downstream + , Just i <- [ firstInsn pc ] ] + , dvRuns = length ds + } + where + pcs = S.unions (fmap (M.keysSet . dpInsns) ds) + firstInsn pc = case [ i | d <- ds + , Just i <- [ M.lookup pc (dpInsns d) ] ] of + (i : _) -> Just i + [] -> Nothing + + -- run counts per run, absent counted as never run + countsAt pc = [ maybe 0 inCount (M.lookup pc (dpInsns d)) | d <- ds ] + alike xs = case xs of + [] -> True + (x : rest) -> all (== x) rest + + steady = S.filter (alike . countsAt) pcs + + -- an instruction's run count and transfers, in each run that ran + -- it + ranAt pc = + [ (inCount i, M.findWithDefault M.empty pc (dpTransfers d)) + | d <- ds, Just i <- [ M.lookup pc (dpInsns d) ], inCount i > 0 ] + transferSources = S.unions (fmap (M.keysSet . dpTransfers) ds) + + -- outcomes in the same proportion: each target taken as often, + -- relative to the run count, in both + proportional (c, ts) (c', ts') = + M.keysSet ts == M.keysSet ts' + && and (M.elems (M.intersectionWith (\e e' -> e * c' == e' * c) + ts ts')) + divergent pc = case ranAt pc of + (r : rest) -> not (all (proportional r) rest) + [] -> False + + controlPcs = S.filter divergent transferSources + downstream = (pcs `S.difference` steady) `S.difference` controlPcs + + controls = + [ (ControlOrigin, i) + | pc <- S.toAscList controlPcs + , Just i <- [ firstInsn pc ] ] + + addresses = + [ (AddressOrigin, i) + | pc <- S.toAscList steady + , let fps = [ fmap inFingerprint (M.lookup pc (dpInsns d)) + | d <- ds ] + , not (alike fps) + , Just i <- [ firstInsn pc ] + , inAccesses i > 0 ] + +-- | Only the origins in objects whose path contains one of the +-- given texts; downstream instructions are kept, to be counted. +restrictTo :: [T.Text] -> Divergence -> Divergence +restrictTo ps dv + | null ps = dv + | otherwise = dv { dvOrigins = filter (inside . snd) (dvOrigins dv) } + where + inside i = case inObject i of + Just o -> any (`T.isInfixOf` o) ps + Nothing -> False + +originText :: Origin -> T.Text +originText o = case o of + ControlOrigin -> "control" + AddressOrigin -> "address" + +-- | The report: @vestigia/diff-v1@. +divergenceJson + :: T.Text -> M.Map (T.Text, Integer) [Place] -> Divergence -> Value +divergenceJson version places dv = Object + [ ("schema", String "vestigia/diff-v1") + , ("tool", Object [ ("name", String "vestigia") + , ("version", String version) ]) + , ("runs", Number (toInteger (dvRuns dv))) + , ("summary", Object + [ ("control", count ControlOrigin) + , ("address", count AddressOrigin) + , ("downstream", Number (toInteger (length (dvDownstream dv)))) + ]) + , ("origins", Array + [ Object $ + [ ("class", String (originText o)) + , ("pc", String (hex (inPc i))) + , ("object", maybe Null String (inObject i)) + , ("offset", String (hex (inOffset i))) + , ("count", Number (inCount i)) + , ("frames", Array (fmap placeJson (placesOf i))) + ] + | (o, i) <- dvOrigins dv ]) + , ("downstream", Object + [ (o, Number (toInteger n)) + | (o, n) <- M.toList (M.fromListWith (+) + [ (maybe "-" id (inObject i), 1 :: Int) + | i <- dvDownstream dv ]) ]) + ] + where + count o = Number (toInteger (length + [ () | (o', _) <- dvOrigins dv, o' == o ])) + placesOf i = case inObject i of + Just obj -> M.findWithDefault [] (obj, inOffset i) places + Nothing -> [] + placeJson p = Object + [ ("function", maybe Null String (plFunction p)) + , ("file", maybe Null String (plFile p)) + , ("line", maybe Null (Number . toInteger) (plLine p)) + ] + +-- | One line per origin, innermost frame first, for a terminal. +divergenceSummary + :: M.Map (T.Text, Integer) [Place] -> Divergence -> T.Text +divergenceSummary places dv = T.unlines $ + [ T.intercalate " " + [ originText o + , hex (inOffset i) + , maybe "?" id (plFunction =<< innermost i) + , maybe "?" location (innermost i) + , maybe "-" base (inObject i) ] + | (o, i) <- dvOrigins dv ] + <> [ T.pack (show (length (dvOrigins dv))) <> " origin(s) over " + <> T.pack (show (dvRuns dv)) <> " run(s); " + <> T.pack (show (length (dvDownstream dv))) + <> " instruction(s) downstream" ] + where + innermost i = case inObject i of + Just obj -> case M.findWithDefault [] (obj, inOffset i) places of + (p : _) -> Just p + [] -> Nothing + Nothing -> Nothing + location p = case (plFile p, plLine p) of + (Just f, Just l) -> base f <> ":" <> T.pack (show l) + (Just f, Nothing) -> base f + _ -> "?" + base p = case reverse (T.splitOn "/" p) of + (x : _) -> x + [] -> p + +hex :: Integer -> T.Text +hex n = "0x" <> T.pack (showHex n "") diff --git a/lib/Vestigia/Symbol.hs b/lib/Vestigia/Symbol.hs @@ -0,0 +1,100 @@ +{-# OPTIONS_HADDOCK prune #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | +-- Module: Vestigia.Symbol +-- Copyright: (c) 2026 Jared Tobin +-- License: MIT +-- Maintainer: Jared Tobin <jared@ppad.tech> +-- +-- Naming an instruction by its source: binutils' addr2line over an +-- object's debug information, inlined frames included. An object's +-- file offset is taken as its virtual address, which holds for the +-- executable segments of what GNU ld and lld emit; an object that +-- does not satisfy it names its sites wrongly, not silently: its +-- functions will not match its disassembly. + +module Vestigia.Symbol ( + Place(..) + , symbolize + , parseAddr2line + ) where + +import Control.Exception (IOException, try) +import qualified Data.Map.Strict as M +import qualified Data.Text as T +import Numeric (readHex, showHex) +import System.Process (readProcess) +import Text.Read (readMaybe) + +-- | One frame of a source location: the function and, where the +-- debug information has it, the file and line. The innermost +-- frame comes first. +data Place = Place { + plFunction :: !(Maybe T.Text) + , plFile :: !(Maybe T.Text) + , plLine :: !(Maybe Int) + } deriving (Eq, Show) + +-- | The source of each offset, per object, by one addr2line call +-- per object. An object addr2line cannot read names nothing. +symbolize + :: FilePath -> [(T.Text, Integer)] + -> IO (M.Map (T.Text, Integer) [Place]) +symbolize addr2line keys = do + let byObject = M.fromListWith (<>) [ (o, [ off ]) | (o, off) <- keys ] + parts <- traverse one (M.toList byObject) + pure (M.unions parts) + where + one (obj, offs) = do + r <- try (readProcess addr2line + [ "-a", "-f", "-i", "-C", "-e", T.unpack obj ] + (unlines [ "0x" <> showHex o "" | o <- offs ])) + :: IO (Either IOException String) + pure $ case r of + Left _ -> M.empty + Right out -> M.fromList + [ ((obj, a), ps) | (a, ps) <- parseAddr2line (T.pack out) ] + +-- | addr2line's output under @-a -f -i@: an address line, then a +-- function line and a location line per frame. +-- +-- >>> parseAddr2line "0x20\n??\n??:0\n" +-- [(32,[Place Nothing Nothing Nothing])] +parseAddr2line :: T.Text -> [(Integer, [Place])] +parseAddr2line = go . T.lines + where + go ls = case ls of + (a : rest) | Just addr <- address a -> + let (body, more) = break (\l -> address l /= Nothing) rest + in (addr, frames body) : go more + (_ : rest) -> go rest + [] -> [] + + address :: T.Text -> Maybe Integer + address l = case T.stripPrefix "0x" l of + Just h | not (T.null h), [ (v, "") ] <- readHex (T.unpack h) -> + Just v + _ -> Nothing + + frames ls = case ls of + (f : loc : rest) -> place f loc : frames rest + _ -> [] + + place f loc = + let (file, line) = location loc + in Place { + plFunction = if f == "??" then Nothing else Just f + , plFile = file + , plLine = line + } + + -- "path:line", "path:line (discriminator n)", or "??:0" + location loc = + let l = T.takeWhile (/= ' ') loc + (p, n) = T.breakOnEnd ":" l + path = T.dropEnd 1 p + in ( if T.null path || path == "??" then Nothing else Just path + , case readMaybe (T.unpack n) of + Just 0 -> Nothing + other -> other ) diff --git a/plugin/vestigia-qemu.c b/plugin/vestigia-qemu.c @@ -0,0 +1,203 @@ +/* + * vestigia-qemu.c: a QEMU user-mode plugin recording a run's leakage + * trace, summarised per instruction. + * + * Copyright (c) 2026 Jared Tobin + * License: MIT + * + * For every guest instruction executed it records how many times it + * ran, every non-sequential transfer out of it (a branch taken, a + * call, a jump, a return) with its count, and an order-sensitive + * fingerprint of the data addresses it touched. Two runs of a + * program that differ only in a secret then differ exactly where + * the execution depended on the secret: a transfer whose counts + * differ is a secret-dependent branch, and an instruction run as + * often but touching other addresses computed one from the secret. + * Nothing needs aligning, and no taint is propagated, so effects of + * control flow are seen as well as those of data flow. + * + * At exit it writes, to the file named by its `out` argument: + * + * I <pc> <count> <accesses> <fingerprint> <object> <offset> + * E <from> <to> <count> + * + * in hex, where <object> is the file the instruction's bytes were + * mapped from ("-" if none) and <offset> its offset in that file. + * + * A single-threaded guest is assumed: the state below is unlocked. + */ +#include <glib.h> +#include <inttypes.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include <qemu-plugin.h> + +QEMU_PLUGIN_EXPORT int qemu_plugin_version = QEMU_PLUGIN_VERSION; + +typedef struct Insn { + uint64_t pc; + uint64_t size; + uintptr_t haddr; + uint64_t count; + uint64_t accesses; + uint64_t fingerprint; + GHashTable *succ; /* target pc -> count, for transfers */ +} Insn; + +static GHashTable *insns; /* pc -> Insn */ +static Insn *prev; /* the instruction executed last */ +static char *out_path; + +static const uint64_t fnv_offset = 0xcbf29ce484222325ULL; +static const uint64_t fnv_prime = 0x100000001b3ULL; + +static Insn *insn_at(uint64_t pc, uint64_t size, uintptr_t haddr) { + Insn *i = g_hash_table_lookup(insns, &pc); + if (i == NULL) { + i = g_new0(Insn, 1); + i->pc = pc; + i->size = size; + i->haddr = haddr; + i->fingerprint = fnv_offset; + i->succ = g_hash_table_new_full(g_int64_hash, g_int64_equal, + g_free, g_free); + g_hash_table_insert(insns, &i->pc, i); + } + return i; +} + +static void transfer(Insn *from, uint64_t to) { + uint64_t *n = g_hash_table_lookup(from->succ, &to); + if (n == NULL) { + uint64_t *k = g_new(uint64_t, 1); + *k = to; + n = g_new0(uint64_t, 1); + g_hash_table_insert(from->succ, k, n); + } + (*n)++; +} + +static void on_exec(unsigned int vcpu, void *udata) { + Insn *i = udata; + (void)vcpu; + if (prev != NULL && i->pc != prev->pc + prev->size) + transfer(prev, i->pc); + prev = i; + i->count++; +} + +static void on_mem(unsigned int vcpu, qemu_plugin_meminfo_t info, + uint64_t vaddr, void *udata) { + Insn *i = udata; + (void)vcpu; + (void)info; + i->fingerprint = (i->fingerprint ^ vaddr) * fnv_prime; + i->accesses++; +} + +static void on_translate(qemu_plugin_id_t id, struct qemu_plugin_tb *tb) { + size_t n = qemu_plugin_tb_n_insns(tb); + (void)id; + for (size_t k = 0; k < n; k++) { + struct qemu_plugin_insn *qi = qemu_plugin_tb_get_insn(tb, k); + Insn *i = insn_at(qemu_plugin_insn_vaddr(qi), + qemu_plugin_insn_size(qi), + (uintptr_t)qemu_plugin_insn_haddr(qi)); + qemu_plugin_register_vcpu_insn_exec_cb(qi, on_exec, + QEMU_PLUGIN_CB_NO_REGS, i); + qemu_plugin_register_vcpu_mem_cb(qi, on_mem, QEMU_PLUGIN_CB_NO_REGS, + QEMU_PLUGIN_MEM_RW, i); + } +} + +/* the host's file-backed mappings, for naming an instruction's file */ +typedef struct Region { + uintptr_t start, end; + uint64_t offset; + char *path; +} Region; + +static GArray *read_maps(void) { + GArray *rs = g_array_new(FALSE, FALSE, sizeof(Region)); + FILE *f = fopen("/proc/self/maps", "r"); + char line[4096]; + if (f == NULL) return rs; + while (fgets(line, sizeof line, f) != NULL) { + uintptr_t start, end; + uint64_t offset; + char perms[8], dev[16]; + unsigned long inode; + int used = 0; + if (sscanf(line, "%" SCNxPTR "-%" SCNxPTR " %7s %" SCNx64 + " %15s %lu %n", &start, &end, perms, &offset, dev, + &inode, &used) < 6) + continue; + char *path = line + used; + path[strcspn(path, "\n")] = '\0'; + if (inode == 0 || path[0] != '/') continue; + Region r = { start, end, offset, g_strdup(path) }; + g_array_append_val(rs, r); + } + fclose(f); + return rs; +} + +static const Region *region_of(GArray *rs, uintptr_t a) { + for (guint k = 0; k < rs->len; k++) { + const Region *r = &g_array_index(rs, Region, k); + if (a >= r->start && a < r->end) return r; + } + return NULL; +} + +static void at_exit(qemu_plugin_id_t id, void *p) { + (void)id; + (void)p; + FILE *out = fopen(out_path, "w"); + if (out == NULL) { + fprintf(stderr, "vestigia-qemu: cannot write %s\n", out_path); + return; + } + GArray *rs = read_maps(); + GHashTableIter it; + gpointer key, val; + g_hash_table_iter_init(&it, insns); + while (g_hash_table_iter_next(&it, &key, &val)) { + Insn *i = val; + const Region *r = region_of(rs, i->haddr); + if (r != NULL) + fprintf(out, "I %" PRIx64 " %" PRIx64 " %" PRIx64 " %016" PRIx64 + " %s %" PRIx64 "\n", i->pc, i->count, i->accesses, + i->fingerprint, r->path, + (uint64_t)(i->haddr - r->start) + r->offset); + else + fprintf(out, "I %" PRIx64 " %" PRIx64 " %" PRIx64 " %016" PRIx64 + " - 0\n", i->pc, i->count, i->accesses, i->fingerprint); + GHashTableIter jt; + gpointer tk, tn; + g_hash_table_iter_init(&jt, i->succ); + while (g_hash_table_iter_next(&jt, &tk, &tn)) + fprintf(out, "E %" PRIx64 " %" PRIx64 " %" PRIx64 "\n", i->pc, + *(uint64_t *)tk, *(uint64_t *)tn); + } + fclose(out); +} + +QEMU_PLUGIN_EXPORT int qemu_plugin_install(qemu_plugin_id_t id, + const qemu_info_t *info, + int argc, char **argv) { + (void)info; + for (int k = 0; k < argc; k++) + if (strncmp(argv[k], "out=", 4) == 0) + out_path = g_strdup(argv[k] + 4); + if (out_path == NULL) { + fprintf(stderr, "vestigia-qemu: an out=FILE argument is needed\n"); + return -1; + } + insns = g_hash_table_new(g_int64_hash, g_int64_equal); + qemu_plugin_register_vcpu_tb_trans_cb(id, on_translate); + qemu_plugin_register_atexit_cb(id, at_exit, NULL); + return 0; +} diff --git a/test/Main.hs b/test/Main.hs @@ -5,13 +5,16 @@ module Main ( ) where import qualified Data.ByteString as BS +import qualified Data.Map.Strict as M import qualified Data.Text as T import qualified Data.Text.Encoding as TE import Test.Tasty import Test.Tasty.HUnit +import Vestigia.Diff import Vestigia.Json import Vestigia.Memcheck +import Vestigia.Symbol import Vestigia.Trace import Vestigia.Xml @@ -20,6 +23,8 @@ main = defaultMain $ testGroup "vestigia" [ xmlTests , memcheckTests , traceTests + , diffTests + , symbolTests , jsonTests ] @@ -96,6 +101,83 @@ traceTests = testGroup "trace" @?= [ (0x10, 7, 2) ] ] +diffTests :: TestTree +diffTests = testGroup "diff" + [ testCase "a dump reads whole" $ do + d <- either assertFailure pure (readDump dumpA) + fmap inCount (dpInsns d) @?= M.fromList + [ (0x10, 4), (0x20, 4), (0x30, 2), (0x40, 1) ] + M.lookup 0x10 (fmap inObject (dpInsns d)) @?= Just (Just "/lib.so") + dpTransfers d @?= M.fromList + [ (0x10, M.fromList [ (0x30, 2) ]) ] + + , testCase "origins are where steady instructions differ" $ do + a <- either assertFailure pure (readDump dumpA) + b <- either assertFailure pure (readDump dumpB) + let dv = diffDumps [ a, b ] + fmap (\(o, i) -> (o, inPc i)) (dvOrigins dv) + @?= [ (ControlOrigin, 0x10), (AddressOrigin, 0x20) ] + fmap inPc (dvDownstream dv) @?= [ 0x30 ] + + , testCase "a loop the secret sets is an origin" $ do + a <- either assertFailure pure (readDump loopA) + b <- either assertFailure pure (readDump loopB) + let dv = diffDumps [ a, b ] + fmap (\(o, i) -> (o, inPc i)) (dvOrigins dv) + @?= [ (ControlOrigin, 0x10) ] + fmap inPc (dvDownstream dv) @?= [ 0x14, 0x18 ] + + , testCase "a branch downstream, always taken, is no origin" $ do + a <- either assertFailure pure (readDump loopA) + b <- either assertFailure pure (readDump loopB) + let dv = diffDumps [ a, b ] + notElem 0x18 (fmap (inPc . snd) (dvOrigins dv)) + @? "a branch that always jumps is not secret-dependent" + + , testCase "identical runs have no origins" $ do + a <- either assertFailure pure (readDump dumpA) + let dv = diffDumps [ a, a ] + dvOrigins dv @?= [] + dvDownstream dv @?= [] + + , testCase "an unreadable line is refused" $ + isLeft (readDump "I 10 zz 0 f /lib.so 0\n") @? "accepted" + ] + where + -- 0x10 is a branch run four times, taken twice in A and three + -- times in B; 0x20 reads other addresses in B; 0x30, inside the + -- branch, runs a different number of times; 0x40 is the same + dumpA = T.unlines + [ "I 10 4 0 aaaa /lib.so 10", "E 10 30 2" + , "I 20 4 4 bbbb /lib.so 20", "I 30 2 0 cccc /lib.so 30" + , "I 40 1 1 dddd /lib.so 40" ] + dumpB = T.unlines + [ "I 10 4 0 aaaa /lib.so 10", "E 10 30 3" + , "I 20 4 4 eeee /lib.so 20", "I 30 3 0 cccc /lib.so 30" + , "I 40 1 1 dddd /lib.so 40" ] + -- a loop at 0x10 the secret runs 5 or 9 times, its back edge to + -- 0x14 taken every time but the last; 0x18, in the body, always + -- jumps back to 0x10 + loopA = T.unlines + [ "I 10 5 0 aaaa /lib.so 10", "E 10 14 4" + , "I 14 4 0 bbbb /lib.so 14" + , "I 18 4 0 cccc /lib.so 18", "E 18 10 4" ] + loopB = T.unlines + [ "I 10 9 0 aaaa /lib.so 10", "E 10 14 8" + , "I 14 8 0 bbbb /lib.so 14" + , "I 18 8 0 cccc /lib.so 18", "E 18 10 8" ] + +symbolTests :: TestTree +symbolTests = testGroup "symbol" + [ testCase "addr2line's inlined frames and discriminators" $ + parseAddr2line (T.unlines + [ "0x0000000000001234", "inner", "/src/a.rs:12 (discriminator 3)" + , "outer", "/src/b.rs:40", "0x0000000000005678", "??", "??:0" ]) + @?= [ (0x1234, [ Place (Just "inner") (Just "/src/a.rs") (Just 12) + , Place (Just "outer") (Just "/src/b.rs") (Just 40) ]) + , (0x5678, [ Place Nothing Nothing Nothing ]) ] + ] + jsonTests :: TestTree jsonTests = testGroup "json" [ testCase "objects keep their order and strings escape" $ diff --git a/vestigia.cabal b/vestigia.cabal @@ -27,13 +27,16 @@ library ghc-options: -Wall -Werror exposed-modules: - Vestigia.Json + Vestigia.Diff + , Vestigia.Json , Vestigia.Memcheck + , Vestigia.Symbol , Vestigia.Trace , Vestigia.Xml build-depends: base >= 4.9 && < 5 , containers + , process , text executable vestigia @@ -64,6 +67,7 @@ test-suite vestigia-tests build-depends: base , bytestring + , containers , tasty , tasty-hunit , text