vestigia

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

commit 87dfbfa3744d0903c988918f841aff3e48719dff
parent c6911c285ec337df8733870c7e5c434aceb1ea26
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat, 31 Oct 2026 13:22:12 +0400

merge impl/cpu-models: a CPU model for QEMU to emulate

record --cpu MODEL has QEMU emulate MODEL (its -cpu) and records it in
the dump; diff refuses runs recorded on other models and names the
model in its report; diffOf takes cpu. QEMU's AArch64 models take
crypto=off (qemu-crypto.patch), removing the cryptographic extension
as a CPU without it reports.

Diffstat:
MCHANGELOG | 6++++++
Mapp/Main.hs | 47+++++++++++++++++++++++++++++++++++------------
Mlib/Vestigia/Diff.hs | 27++++++++++++++++++---------
Mnix/lib.nix | 26++++++++++++++++++--------
Anix/qemu-crypto.patch | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/Main.hs | 5+++++
6 files changed, 147 insertions(+), 29 deletions(-)

diff --git a/CHANGELOG b/CHANGELOG @@ -21,6 +21,12 @@ * `read` resolves the objects a harness reports to the files they name, as memcheck names a frame's object, so a library reached by its soname's link has its sites placed by offset. + * `record --cpu MODEL` has QEMU emulate MODEL (its -cpu), which the + dump records (a `P` line); `diff` refuses runs recorded on other + models and names the model in its report; `diffOf` takes `cpu`. + * QEMU's AArch64 models take `crypto=off`, removing the + cryptographic extension as a CPU without it reports + (qemu-crypto.patch). - 0.1.0 (2026-10-22) * Initial version, with two instruments over a harness that marks diff --git a/app/Main.hs b/app/Main.hs @@ -71,6 +71,7 @@ data Opts = Opts { , oContract :: !(Maybe T.Text) , oObjdump :: !(Maybe FilePath) , oWatch :: !(Maybe FilePath) + , oCpu :: !(Maybe String) } main :: IO () @@ -108,11 +109,12 @@ usage e = do , " vestigia run [--valgrind PATH] [--dir DIR] [--out FILE] \ \[--object TEXT]... -- PROGRAM [ARG...]" , " vestigia read [--out FILE] [--object TEXT]... RUN..." - , " vestigia record [--qemu PATH] [--plugin PATH] --dump FILE \ - \[--contract NAME [--watch FILE | [--objdump PATH] \ + , " vestigia record [--qemu PATH] [--plugin PATH] [--cpu MODEL] \ + \--dump FILE [--contract NAME [--watch FILE | [--objdump PATH] \ \[--object TEXT]...]] -- PROGRAM [ARG...]" - , " vestigia watch [--qemu PATH] [--out FILE] --contract NAME \ - \[--objdump PATH] [--object TEXT]... -- PROGRAM [ARG...]" + , " vestigia watch [--qemu PATH] [--cpu MODEL] [--out FILE] \ + \--contract NAME [--objdump PATH] [--object TEXT]... -- PROGRAM \ + \[ARG...]" , " vestigia diff [--out FILE] [--addr2line PATH] \ \[--object TEXT]... [--control DUMP]... [--designed DUMP]... \ \DUMP DUMP..." @@ -139,6 +141,9 @@ usage e = do , "operands differed beside the origins. watch writes the list of" , "those instructions once, for record --watch to read in runs of" , "one program rather than make anew in each." + , "--cpu has QEMU emulate MODEL (its -cpu, with any properties)," + , "which the dump records; a program choosing its code by the" + , "CPU's features takes the path MODEL's features lead it to." , "--object keeps origins in objects whose path contains TEXT;" , "--control names a run repeating the first run's secret, and" , "what differs in it is unsteady, not an origin." @@ -152,7 +157,7 @@ usage e = do -- options before the program; "--" ends them options :: [String] -> Either String (Opts, [String]) options = go (Opts Nothing Nothing Nothing Nothing Nothing Nothing - Nothing [] [] [] Nothing Nothing Nothing) + Nothing [] [] [] Nothing Nothing Nothing Nothing) where go o xs = case xs of ("--" : rest) -> Right (o, rest) @@ -172,6 +177,7 @@ options = go (Opts Nothing Nothing Nothing Nothing Nothing Nothing ("--contract" : c : rest) -> go o { oContract = Just (T.pack c) } rest ("--objdump" : p : rest) -> go o { oObjdump = Just p } rest ("--watch" : p : rest) -> go o { oWatch = Just p } rest + ("--cpu" : c : rest) -> go o { oCpu = Just c } rest (x : _) | take 2 x == "--" -> Left ("unknown option: " <> x) _ -> Right (o, xs) @@ -366,6 +372,7 @@ record o prog progArgs = do others = filter ((/= tunables) . fst) environment r <- try (withCreateProcess ((proc qemu (guest + <> cpuArgs o <> [ "-seed", "0" , "-plugin", plugin <> ",out=" <> dump <> maybe "" (",watch=" <>) watchFile @@ -385,8 +392,13 @@ record o prog progArgs = do -- environment is QEMU's, with its own tunables restored appendRun dump (prog : progArgs) (others <> [ (tunables, v) | Just v <- [ own ] ]) c isa contract + (oCpu o) exitWith c +-- QEMU's -cpu, where a model is named +cpuArgs :: Opts -> [String] +cpuArgs o = maybe [] (\m -> [ "-cpu", m ]) (oCpu o) + refuseRecord :: String -> IO a refuseRecord e = do hPutStrLn stderr ("vestigia: " <> e) @@ -421,7 +433,7 @@ watchList :: Opts -> FilePath -> Contract -> FilePath -> [String] -> IO T.Text watchList o qemu c prog progArgs = do objdump <- tool (oObjdump o) "VESTIGIA_OBJDUMP" "llvm-objdump" - loaded <- loadedObjects qemu prog progArgs + loaded <- loadedObjects qemu (cpuArgs o) prog progArgs objects <- fmap L.nub (traverse canonicalizePath (prog : loaded)) let kept = [ p | p <- objects , null (oObjects o) @@ -434,10 +446,11 @@ watchList o qemu c prog progArgs = do -- the shared objects a program loads, as its dynamic loader resolves -- them, run under QEMU so that any ISA's are found -loadedObjects :: FilePath -> FilePath -> [String] -> IO [FilePath] -loadedObjects qemu prog progArgs = do +loadedObjects :: FilePath -> [String] -> FilePath -> [String] + -> IO [FilePath] +loadedObjects qemu cpu prog progArgs = do r <- try (readCreateProcessWithExitCode - (proc qemu ([ "-E", "LD_TRACE_LOADED_OBJECTS=1", prog ] + (proc qemu (cpu <> [ "-E", "LD_TRACE_LOADED_OBJECTS=1", prog ] <> progArgs)) "") case r of Left e -> refuseRecord ("cannot run " <> qemu <> ": " @@ -495,10 +508,10 @@ machineOf prog = do -- append a run's record to its dump (Vestigia.Diff.readDump): its -- arguments' bytes, its environment's variables and the bytes they -- take, its exit status, the guest's ISA, and any contract it was --- recorded under +-- recorded under and CPU model QEMU emulated appendRun :: FilePath -> [String] -> [(String, String)] -> ExitCode - -> String -> Maybe Contract -> IO () -appendRun dump args env code isa contract = do + -> String -> Maybe Contract -> Maybe String -> IO () +appendRun dump args env code isa contract cpu = do enc <- getFileSystemEncoding let bytes s = GF.withCStringLen enc s BS.packCStringLen as <- traverse bytes args @@ -511,6 +524,7 @@ appendRun dump args env code isa contract = do , "X " <> show (exitStatus code) , "M " <> isa ] <> [ "G " <> T.unpack (contractName c) | Just c <- [ contract ] ] + <> [ "P " <> m | Just m <- [ cpu ] ] where byte w = case showHex w "" of [ d ] -> [ '0', d ] @@ -553,6 +567,15 @@ diff o paths = do \contract than " <> p0 <> "; runs compared must watch the \ \same instructions") [] -> pure () + -- runs on other CPU models may take other code wherever a program + -- chooses its code by the CPU's features + case zip (paths <> oDesigned o <> oControls o) + (dumps <> designed <> controls) of + ((p0, first) : rest) -> for_ rest $ \(p, d) -> + when (dpCpu d /= dpCpu first) $ + refuse (p <> " was recorded on another CPU model than " <> p0 + <> "; runs compared must run on the same one") + [] -> pure () -- runs laid out apart differ wherever they touch memory, not by -- the secret: such runs are refused, not compared case zip paths dumps of diff --git a/lib/Vestigia/Diff.hs b/lib/Vestigia/Diff.hs @@ -150,10 +150,11 @@ data Dump = Dump { , dpOutside :: !(M.Map Integer T.Text) -- | the text of each instruction the watch list named , dpTexts :: !(M.Map Integer T.Text) - -- | the guest's ISA, and the contract, as `vestigia record` - -- appends them + -- | the guest's ISA, the contract, and the CPU model QEMU + -- emulated, as `vestigia record` appends them , dpIsa :: !(Maybe T.Text) , dpContract :: !(Maybe T.Text) + , dpCpu :: !(Maybe T.Text) } deriving (Eq, Show) -- | Read a plugin dump. A dump in which no instruction ran records @@ -164,12 +165,14 @@ data Dump = Dump { -- program's own. The record `vestigia record` appends (an @A@ line -- per argument, its bytes in hex; @N@, the environment's variables -- and bytes, in hex; @X@, the exit status, in decimal) is read --- whole or not at all. Beside it, @M@ names the guest's ISA and @G@ --- the contract the run was recorded under. +-- whole or not at all. Beside it, @M@ names the guest's ISA, @G@ +-- the contract the run was recorded under, and @P@ the CPU model +-- QEMU emulated, where one was named. readDump :: T.Text -> Either String Dump readDump t = do d <- foldM line (Dump M.empty M.empty S.empty M.empty M.empty M.empty - Nothing M.empty M.empty Nothing Nothing, [], Nothing + Nothing M.empty M.empty Nothing Nothing Nothing, [] + , Nothing , Nothing) (T.lines t) let (dump, as, env, st) = d cx = dpContexts dump @@ -216,6 +219,7 @@ readDump t = do , env, st) [ "M", isa ] -> pure (d { dpIsa = Just isa }, as, env, st) [ "G", c ] -> pure (d { dpContract = Just c }, as, env, st) + [ "P", c ] -> pure (d { dpCpu = Just c }, as, env, st) [ "C", c, caller, call ] -> do v <- (,) <$> unhex caller <*> unhex call k <- unhex c @@ -363,10 +367,11 @@ data Divergence = Divergence { , dvWatched :: !(M.Map T.Text (S.Set Integer)) -- | the text of each instruction the watch list named , dvTexts :: !(M.Map Integer T.Text) - -- | the guest's ISA and the contract, as the declared runs name - -- them + -- | the guest's ISA, the contract, and the CPU model, as the + -- declared runs name them , dvIsa :: !(Maybe T.Text) , dvContract :: !(Maybe T.Text) + , dvCpu :: !(Maybe T.Text) } deriving (Eq, Show) -- | Compare runs, uncontrolled. @@ -429,6 +434,9 @@ diffDesigned cs ds xs = Divergence { , dvContract = case ds of (d : _) -> dpContract d [] -> Nothing + , dvCpu = case ds of + (d : _) -> dpCpu d + [] -> Nothing } where ranIn' rs pc = [ (k, d) | (k, d) <- zip [ 0 .. ] rs @@ -612,8 +620,8 @@ originText o = case o of -- runs alike at it by the first two. An origin's stacks are its own -- frames then its calls', innermost first, each frame with the -- object and address of the instruction it names, as memcheck's --- are in a taint report. The ISA and contract the runs were --- recorded under head it; the instructions outside the contract +-- are in a taint report. The ISA, contract, and CPU model the runs +-- were recorded under head it; the instructions outside the contract -- whose operands differed are given as origins are, and those that -- ran, by object, as what was executed is. divergenceJson @@ -630,6 +638,7 @@ divergenceJson version runs places dv = Object , ("source", maybe Null (String . contractSource) (readContract n)) ] Nothing -> Null) + , ("cpu", maybe Null String (dvCpu dv)) , ("runs", Array [ Object [ ("name", String (nmName r)) diff --git a/nix/lib.nix b/nix/lib.nix @@ -27,12 +27,16 @@ let # instruction, and the QEMU that runs a harness under it, for # either ISA, whatever the host's: user mode alone, its guest's # getrandom drawn from the generator -seed fixes - # (qemu-getrandom.patch) + # (qemu-getrandom.patch), and an Arm CPU without the cryptographic + # extension to be had (crypto=off, qemu-crypto.patch) qemu = (pkgs.qemu.override { userOnly = true; hostCpuTargets = [ "x86_64-linux-user" "aarch64-linux-user" ]; }).overrideAttrs (o: { - patches = (o.patches or [ ]) ++ [ ./qemu-getrandom.patch ]; + patches = (o.patches or [ ]) ++ [ + ./qemu-getrandom.patch + ./qemu-crypto.patch + ]; }); plugin = pkgs.runCommandCC "vestigia-qemu-plugin" { nativeBuildInputs = [ pkgs.pkg-config ]; @@ -116,12 +120,17 @@ let # diff --designed). Under a `contract` (arm-dit or intel-doitm, the # harness's ISA's), every run also fingerprints the operands of the # instructions outside it in `objects`, listed once (vestigia watch, - # kept as `watch`) for every run to read (record --watch). + # kept as `watch`) for every run to read (record --watch). On a + # `cpu`, every run is recorded with QEMU emulating that model (its + # -cpu, properties and all), so that a harness choosing its code by + # the CPU's features runs the code that model leads it to. # A run's own failure leaves its status; a dump vestigia cannot read # fails the build. diffOf = { name, harness, runs, objects ? [ ], designed ? [ ] - , contract ? null }: + , contract ? null, cpu ? null }: let + model = lib.optionalString (cpu != null) + "--cpu ${lib.escapeShellArg cpu} "; watch = lib.optionalString (contract != null) "--contract ${lib.escapeShellArg contract} --watch $out/watch "; in @@ -129,7 +138,7 @@ let mkdir -p $out start=$(date +%s%N) ${lib.optionalString (contract != null) '' - ${vestigia}/bin/vestigia watch --out $out/watch \ + ${vestigia}/bin/vestigia watch --out $out/watch ${model}\ --contract ${lib.escapeShellArg contract} \ ${lib.concatMapStrings (o: "--object ${lib.escapeShellArg o} ") objects}-- ${harness} \ @@ -138,7 +147,7 @@ let ${lib.concatImapStrings (k: args: '' set +e ${vestigia}/bin/vestigia record --dump $out/dump-${toString k} \ - ${watch}-- ${harness} ${lib.escapeShellArgs args} \ + ${model}${watch}-- ${harness} ${lib.escapeShellArgs args} \ > $out/stdout-${toString k} 2> $out/stderr-${toString k} echo $? > $out/status-${toString k} set -e @@ -146,7 +155,7 @@ let set +e ${vestigia}/bin/vestigia record \ --dump $out/designed-${toString k} \ - ${watch}-- ${harness} ${lib.escapeShellArgs args} \ + ${model}${watch}-- ${harness} ${lib.escapeShellArgs args} \ > $out/stdout-designed-${toString k} \ 2> $out/stderr-designed-${toString k} echo $? > $out/status-designed-${toString k} @@ -154,7 +163,8 @@ let '') designed} set +e ${vestigia}/bin/vestigia record --dump $out/control-1 \ - ${watch}-- ${harness} ${lib.escapeShellArgs (builtins.head runs)} \ + ${model}${watch}-- ${harness} \ + ${lib.escapeShellArgs (builtins.head runs)} \ > $out/stdout-control-1 2> $out/stderr-control-1 echo $? > $out/status-control-1 set -e diff --git a/nix/qemu-crypto.patch b/nix/qemu-crypto.patch @@ -0,0 +1,65 @@ +QEMU's Arm CPU models all have the cryptographic extension (AES, +PMULL, SHA1, SHA2, SHA3, SHA512, SM3, SM4), and none can be told to +lack it, while CPUs built without it (a Raspberry Pi 4's Cortex-A72) +run in the field: a program choosing its code by the CPU's features +runs other code on them than any emulated CPU leads it to. crypto=off, +on any AArch64 model, clears the extension's ID register fields, as +such a CPU reports them: the instructions UNDEF, and a guest's HWCAP +and ID register reads say they are absent. + +--- a/target/arm/cpu.c ++++ b/target/arm/cpu.c +@@ -1581,6 +1581,41 @@ + ARMCPU *cpu = ARM_CPU(obj); + + return arm_feature(&cpu->env, ARM_FEATURE_AARCH64); ++} ++ ++static bool aarch64_cpu_get_crypto(Object *obj, Error **errp) ++{ ++ ARMCPU *cpu = ARM_CPU(obj); ++ ++ return cpu_isar_feature(aa64_aes, cpu); ++} ++ ++/* ++ * crypto=off removes the cryptographic extension (AES, PMULL, SHA1, ++ * SHA2, SHA3, SHA512, SM3, SM4), as a CPU built without it reports, ++ * from the ID registers: the instructions UNDEF, and a guest's ++ * HWCAP and ID register reads say they are absent. ++ */ ++static void aarch64_cpu_set_crypto(Object *obj, bool value, Error **errp) ++{ ++ ARMCPU *cpu = ARM_CPU(obj); ++ ++ if (value) { ++ if (!cpu_isar_feature(aa64_aes, cpu)) { ++ error_setg(errp, "'crypto' feature cannot be enabled on " ++ "this CPU"); ++ } ++ return; ++ } ++ FIELD_DP64_IDREG(&cpu->isar, ID_AA64ISAR0, AES, 0); ++ FIELD_DP64_IDREG(&cpu->isar, ID_AA64ISAR0, SHA1, 0); ++ FIELD_DP64_IDREG(&cpu->isar, ID_AA64ISAR0, SHA2, 0); ++ FIELD_DP64_IDREG(&cpu->isar, ID_AA64ISAR0, SHA3, 0); ++ FIELD_DP64_IDREG(&cpu->isar, ID_AA64ISAR0, SM3, 0); ++ FIELD_DP64_IDREG(&cpu->isar, ID_AA64ISAR0, SM4, 0); ++ FIELD_DP32_IDREG(&cpu->isar, ID_ISAR5, AES, 0); ++ FIELD_DP32_IDREG(&cpu->isar, ID_ISAR5, SHA1, 0); ++ FIELD_DP32_IDREG(&cpu->isar, ID_ISAR5, SHA2, 0); + } + + static void aarch64_cpu_set_aarch64(Object *obj, bool value, Error **errp) +@@ -1738,6 +1773,11 @@ + object_property_set_description(obj, "aarch64", + "Set on/off to enable/disable aarch64 " + "execution state "); ++ object_property_add_bool(obj, "crypto", aarch64_cpu_get_crypto, ++ aarch64_cpu_set_crypto); ++ object_property_set_description(obj, "crypto", ++ "Set off to remove the " ++ "cryptographic extension"); + } + #ifndef CONFIG_USER_ONLY + if (arm_feature(&cpu->env, ARM_FEATURE_CBAR) || diff --git a/test/Main.hs b/test/Main.hs @@ -420,6 +420,11 @@ diffTests = testGroup "diff" (readDump (under dumpA <> "3333\nM x86_64\nG intel-doitm\n")) dpIsa a @?= Just "x86_64" dpContract a @?= Just "intel-doitm" + dpCpu a @?= Nothing + c <- either assertFailure pure + (readDump (dumpA <> "M aarch64\nP max,crypto=off\n")) + dpCpu c @?= Just "max,crypto=off" + dvCpu (diffDumps [ c, c ]) @?= Just "max,crypto=off" M.lookup 0x30 (dpTexts a) @?= Just "popcnt rax, rbx" let dv = diffDumps [ a, b ] -- not an origin: the contract says nothing either way of it