vestigia

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

commit 104efd26339a2471d68d9820cf7417a2ff1d9b0e
parent 9113a280893f9493a01519d7af1510dbc4a36f62
Author: Jared Tobin <jared@jtobin.io>
Date:   Wed, 21 Oct 2026 11:03:16 +0400

merge impl/gaps: the gaps a review found, closed

- checks: a probe harness (etc/probe/harness.c) under both
  instruments, and built for aarch64 differentially on an x86_64
  host, each report held to exactly what the harness does
  (checks.<system>.probe, on Linux).
- plugin: an instruction named by its address in its object, from
  the file's program headers, not its file offset; a GHC harness
  linked at a fixed address had every origin unnamed.
- plugin: a guest that starts a second thread is stopped and its
  dump refused, rather than recorded by threads racing on unlocked
  state.
- record: the run's arguments, environment size, and exit status
  appended to its dump; diff refuses runs of another program or with
  arguments or an environment of another size than the first run's,
  and a control that does not repeat the first run's arguments.
  diff-v3 lists each run with its arguments and status.
- record: QEMU chosen by the harness's ELF machine, so an aarch64
  harness traces on an x86_64 builder.
- diff: a return is downstream, never an origin (Penumbra's `retq`
  origins, four or five a root, gone and nothing else).
- diff and memcheck: an integer division on other operands, or on an
  undefined one, is an `operand` origin or site (the plugin reads
  the operands; nix/memcheck-operand.patch adds UninitOperand), and
  memcheck keeps the instruction pointer exact at every instruction
  so a register division is named where it divides.
- trace: taint over every run, merged (run --dir, read RUN...,
  trace-v2, traceOf's runs), each site with the runs that reached it.
- symbol: LLVM's addr2line, which names inlined frames by their own
  functions and code no symbol covers by none; legacy Rust names
  unescaped.
- diff: each origin with the calls that reached it, from a shadow
  call stack in the plugin, as `stacks` shaped as a taint report's.
- README and CHANGELOG say what changed, and the caveats left: an
  address a secret branch moved, other variable-time instructions,
  GHC's calls and returns by jumping.

Built and tested on pollux: the probe check, the unit suite, and the
Penumbra, poly1305, and secp256k1 spikes.

Diffstat:
MCHANGELOG | 66++++++++++++++++++++++++++++++++++++++++++------------------------
MREADME.md | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mapp/Main.hs | 284++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Aetc/probe/harness.c | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aetc/probe/threads.c | 16++++++++++++++++
Metc/spike/penumbra/default.nix | 2+-
Metc/spike/poly1305/default.nix | 2+-
Metc/spike/secp256k1/default.nix | 2+-
Mflake.nix | 8+++++++-
Mlib/Vestigia/Diff.hs | 327+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mlib/Vestigia/Symbol.hs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mlib/Vestigia/Trace.hs | 243+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mnix/lib.nix | 70+++++++++++++++++++++++++++++++++++++++-------------------------------
Anix/memcheck-operand.patch | 316+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anix/probe.nix | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplugin/vestigia-qemu.c | 417+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtest/Main.hs | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mvestigia.cabal | 2++
18 files changed, 1971 insertions(+), 340 deletions(-)

diff --git a/CHANGELOG b/CHANGELOG @@ -4,30 +4,48 @@ * Initial version, with two instruments over a harness that marks its secret (`vestigia.h`, defined by `vestigia.c`, which serves a foreign function interface too and records where the loader - put each object): - * `vestigia run`: the harness under memcheck with the secret's - bytes undefined, reporting each branch and address computed - from them (vestigia/trace-v1); `vestigia read` reports from - memcheck's XML. - * `vestigia record` and `vestigia diff`: the harness under QEMU - with vestigia's plugin, its leakage trace summarised per - instruction, and where runs differing only in the secret - diverge (vestigia/diff-v2: each origin with the runs alike - there and the runs that never reached it, a branch taking its - outcomes in another order an origin too, and what ran - downstream listed per object), set against controls repeating a - secret, with what every run executed in each object, and runs - designed to reach particular paths compared for control and - coverage alone (`--designed`); runs are recorded with - randomisation off (or not at all) and the guest's entropy, - its getrandom included, seeded, and QEMU's own allocations kept - off the guest's mappings, which under load can otherwise take - the room of a guest library or heap; `diff` refuses runs that - loaded an object at different addresses. A taint run that - marked no secret is refused. - * `traceOf` and `diffOf` run either as a derivation; - `nix/lib.nix` holds them for any package set, with the version - the command reports. + put each object). A taint run that marked no secret is refused. + * `vestigia run`: the harness under memcheck with the secret's + bytes undefined, reporting each branch, address, and integer + division operand computed from them (the last through a memcheck + patch, `nix/memcheck-operand.patch`), each at its own + instruction. `--dir` keeps a run, and `vestigia read` reports on + several kept runs, or memcheck XML made elsewhere, as one + (vestigia/trace-v2: each run with its command, exit, and + objects; each site with the runs that reached it). + * `vestigia record` and `vestigia diff`: the harness under QEMU + with vestigia's plugin, its leakage trace summarised per + instruction, and where runs differing only in the secret diverge + (vestigia/diff-v3): + * each run with how it was made, its arguments and exit status; + * the origins: a branch taking its outcomes in another proportion + or order, an instruction touching other addresses, and an + integer division on other operands, a return never one; each + with the runs alike there, the runs that never reached it, and + the calls that reached it; + * what ran downstream, and what every run executed, per object; + * controls repeating a secret, what differs in them set apart as + unsteady, and runs designed to reach particular paths, compared + for control and coverage alone (`--designed`). + * `record` runs an x86_64 or aarch64 harness on either host, with + randomisation off (or not at all), the guest's entropy, its + getrandom included, seeded, and QEMU's own allocations kept off + the guest's mappings, which under load can otherwise take the + room of a guest library or heap. The plugin names each + instruction by its address in its object, and stops a guest that + starts a second thread. + * `diff` refuses runs that loaded an object at different + addresses; runs of another program, or with arguments or an + environment of another size, than the first run's; a control + that does not repeat the first run's arguments; and a stopped + guest's dump. It names sources with LLVM's addr2line. + * `traceOf` and `diffOf` run either as a derivation, over a list of + runs; `nix/lib.nix` holds them for any package set, with the + version the command reports. * `--object` keeps a taint run's sites (`run`, `read`, and `traceOf`'s `objects`) to objects whose path contains the given text, as it keeps a differential run's origins. + * The flake's probe check (`checks.<system>.probe`, on Linux) holds + both instruments' reports on a known harness to what it does, and + on an x86_64 host the differential one on the same harness built + for aarch64. diff --git a/README.md b/README.md @@ -6,8 +6,8 @@ A Haskell tool that finds where a program's execution depended on a secret, by running it. vestigia executes a harness, reports every instruction that branched -on the secret or computed an address from it, and names each by -function and source line. +on the secret, computed an address from it, or divided by or into +it, and names each by function and source line. A clean report is not a proof of constant-time execution. It covers only the paths the harness drove, and the discipline @@ -39,8 +39,9 @@ can time the same harness. A run that marks nothing is refused. ### Taint: `vestigia run` `run` executes the harness under valgrind's memcheck with the marked -bytes undefined, and reports every branch and address that descends -from them: +bytes undefined, and reports every branch, address, and integer +division operand that descends from them (memcheck reports the last +through vestigia's patch, `nix/memcheck-operand.patch`): ``` $ vestigia run --out trace.json -- ./harness public_key 4 @@ -48,18 +49,23 @@ control 0x4875cd2 MontConfig::from_bigint montgomery_backend.rs:359 x8 control 0x4875cf9 cmp<4> mod.rs:565 x4 ... control 0x4878d4d next<&[u64]> bits.rs:32 x982 -12 secret-dependent site(s); 0 unrelated; 0 fault(s) +12 secret-dependent site(s) over 1 run(s); 0 unrelated; 0 fault(s) ``` -`vestigia read FILE.xml` reports on a memcheck run made elsewhere. +memcheck sees only the path a run takes, so trace a harness on +several secrets: `run --dir DIR` keeps a run (memcheck's XML, where +the harness's objects loaded, its exit status), and `vestigia read +DIR...` reports on such runs as one, each site with the runs that +reached it. `read` takes a memcheck XML file made elsewhere too. `--object TEXT` keeps the sites in objects whose path contains TEXT. ### Differential traces: `vestigia record` and `vestigia diff` `record` runs the harness under QEMU with a plugin that summarises, -per instruction, how often it ran, where it branched, and which -addresses it touched. Record several runs differing only in the -secret, and `diff` reports where they diverge: +per instruction, how often it ran, where it branched, which addresses +it touched, and, for an integer division, which operands it divided. +Record several runs differing only in the secret, and `diff` reports +where they diverge: ``` $ for s in 1 2 3; do @@ -68,23 +74,28 @@ $ for s in 1 2 3; do $ vestigia record --dump control -- ./harness public_key 2 1 $ vestigia diff --object libpenumbra_audit --control control \ > --out diff.json run-* -control 0xe9d6 Element::vartime_compress_to_field sign.rs:11 ... -address 0x152c8 Fq::sqrt_ratio_zeta invsqrt.rs:113 ... +control 0xe9d6 abs<Fq> sign.rs:11 ... +address 0x152c8 Fq::sqrt_ratio_zeta invsqrt.rs:113 ... ... -44 origin(s) over 3 run(s); 1383 instruction(s) downstream; 0 unsteady ... +40 origin(s) over 3 run(s); 1387 instruction(s) downstream; 0 unsteady ... ``` An *origin* is a branch whose outcomes differ between runs, in -proportion or in order, or an instruction whose addresses differ. -Each names the runs that behaved alike there. Instructions that -merely ran a different number of times are listed as downstream, and -every instruction some run executed is listed as coverage. +proportion or in order (`control`), an instruction whose addresses +differ (`address`), or a division, whose time depends on its +operands, on other operands (`operand`). Each names the runs that +behaved alike there, and the calls that reached it, which the plugin +keeps as a shadow stack, as memcheck gives a site its callers. +Instructions that merely ran a different number of times are listed +as downstream, as are returns whose targets differ, which go where +their calls sent them; and every instruction some run executed is +listed as coverage. * `--control DUMP` repeats the first run's secret; whatever differs between the two is *unsteady* and kept out of the origins. * `--designed DUMP` is a run whose secret was chosen to reach a particular path. It counts toward control origins and coverage, - never toward address origins. + never toward address or operand origins. * `--object TEXT` keeps the report to matching objects. `record` disables address-space randomisation and seeds the guest's @@ -96,13 +107,22 @@ arena, large blocks from the heap), and `diff` refuses runs that loaded an object at different addresses. A guest reading `/dev/urandom` still reads the host's. +`record` appends to its dump how the run was made: the program and +its arguments, the size of the guest's environment, and the exit +status, which the report gives for each run. `diff` refuses a run of +another program, or with arguments of another number or length, or an +environment of another size, than the first run's, each of which lays +the guest's stack out apart; and a control that does not repeat the +first run's arguments. + ### As derivations The flake's `lib.<system>` holds `traceOf` and `diffOf`, which run -either instrument as a derivation; an `x86_64-linux` run executes on -any builder for that system, and its report is cached. `diffOf` -repeats the first run as its control. `nix/lib.nix` holds the same -for a consumer's own nixpkgs, beside `version`. +either instrument as a derivation over a list of runs; an +`x86_64-linux` run executes on any builder for that system, and its +report is cached. `diffOf` repeats the first run as its control. +`nix/lib.nix` holds the same for a consumer's own nixpkgs, beside +`version`. ```nix vestigia.lib.x86_64-linux.diffOf { @@ -113,8 +133,8 @@ vestigia.lib.x86_64-linux.diffOf { } ``` -The output holds `trace.json` (`vestigia/trace-v1`) or `diff.json` -(`vestigia/diff-v2`), the raw memcheck XML or plugin dumps, what the +The output holds `trace.json` (`vestigia/trace-v2`) or `diff.json` +(`vestigia/diff-v3`), the runs kept or the plugin's dumps, what the harness printed, and the wall time. ## Two instruments @@ -135,23 +155,36 @@ ppad-poly1305, and ppad-secp256k1. * What a function is meant to reveal (a comparison's result, a public key) must be marked public, or it is reported. * valgrind and QEMU's user mode run on Linux; elsewhere vestigia only - reads their reports. QEMU traces other ISAs differentially, but + reads their reports. `record` runs an x86_64 or aarch64 harness on + either host, choosing QEMU by the program's ELF header, but memcheck needs a native host. -* The plugin assumes a single-threaded guest, and runs compared by - `diff` must differ only in the secret, argument lengths included. +* The plugin records one guest thread: a guest that starts a second + is stopped, and its dump refused. Runs compared by `diff` must + differ only in the secret; it checks their arguments and + environments, not what the harness reads beyond them. +* An address origin can be an address that an earlier secret branch + moved, a heap block or stack frame laid out apart, rather than one + computed from the secret. +* Divisions alone are read for their operands. Floating-point + division and square root, and multiplication on cores that time it + by its operands, are left to a timing instrument. * A GHC harness wants `-with-rtsopts=-xr1G -V0`, against the - runtime's terabyte reservation and its interval timer. -* Source lines need line tables, and sites assume an object's file - offsets are its virtual addresses, as GNU ld and lld emit them. + runtime's terabyte reservation and its interval timer. GHC calls + and returns by jumping, so its origins have no callers, and a + return to a continuation is not known as a return. +* Source lines need line tables. ## Development You'll require [Nix][nixos] with [flake][flake] support enabled. Enter a development shell with `nix develop`, then `cabal build` and -`cabal test`. The spikes under `etc/spike` run on an `x86_64-linux` -builder: +`cabal test`. The probe check runs `etc/probe/harness.c` under both +instruments, and the same harness built for aarch64 differentially, +holding each report to what the harness does; it and the spikes under +`etc/spike` run on an `x86_64-linux` builder: ``` +$ nix build .#checks.x86_64-linux.probe $ nix build --impure -f etc/spike/penumbra traces ``` diff --git a/app/Main.hs b/app/Main.hs @@ -16,34 +16,44 @@ module Main ( import Control.Exception (IOException, try) import Control.Monad (unless, when) import qualified Data.ByteString as BS +import Data.Char (toUpper) +import qualified Data.Map.Strict as M +import Data.Foldable (for_) import Data.Version (showVersion) +import qualified GHC.Foreign as GF +import GHC.IO.Encoding (getFileSystemEncoding) import Numeric (showHex) import qualified Data.Text as T import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding.Error as TEE import qualified Data.Text.IO as TIO -import System.Directory (getTemporaryDirectory, removeFile) +import System.Directory + (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, + getTemporaryDirectory, removeFile) import System.Environment (getArgs, getEnvironment, lookupEnv) import System.Exit (ExitCode(..), exitWith) -import System.FilePath (takeFileName) -import System.IO (hClose, hPutStrLn, openTempFile, stderr) +import System.FilePath (takeFileName, (</>)) +import Text.Read (readMaybe) +import System.IO + (IOMode(..), hClose, hPutStrLn, openTempFile, stderr, withBinaryFile) import System.Process (proc, waitForProcess, withCreateProcess) import qualified System.Process as P import Paths_vestigia (version) import Vestigia.Diff - (diffDesigned, divergenceJson, divergenceSummary, dvOrigins, - inObject, inOffset, layoutApart, readDump, restrictTo) + (Dump(..), Kind(..), Named(..), Run(..), diffDesigned, divergenceJson, + divergenceSummary, dvCallers, dvOrigins, inObject, inOffset, inPc, + layoutApart, readDump, restrictTo, runApart) import Vestigia.Json (encode) import Vestigia.Layout (fixLayout) -import Vestigia.Memcheck (readMemcheck) +import Vestigia.Memcheck (Memcheck, readMemcheck) import Vestigia.Symbol (symbolize) import Vestigia.Trace - (readObjects, sitesIn, trace, traceJson, traceSummary, withObjects) + (Taint(..), readObjects, sitesIn, trace, traceJson, traceSummary) data Opts = Opts { oValgrind :: !(Maybe FilePath) - , oXml :: !(Maybe FilePath) + , oDir :: !(Maybe FilePath) , oOut :: !(Maybe FilePath) , oQemu :: !(Maybe FilePath) , oPlugin :: !(Maybe FilePath) @@ -63,8 +73,8 @@ main = do Right (_, []) -> usage "run: no program given" Left e -> usage e ("read" : rest) -> case options rest of - Right (o, [ xml ]) -> readXml o xml - Right _ -> usage "read: one XML file expected" + Right (o, runs@(_ : _)) -> readRuns o runs + Right _ -> usage "read: a run or memcheck XML file expected" Left e -> usage e ("record" : rest) -> case options rest of Right (o, prog : progArgs) -> record o prog progArgs @@ -82,9 +92,9 @@ usage e = do hPutStrLn stderr ("vestigia: " <> e) hPutStrLn stderr $ unlines [ "usage:" - , " vestigia run [--valgrind PATH] [--xml FILE] [--out FILE] \ + , " vestigia run [--valgrind PATH] [--dir DIR] [--out FILE] \ \[--object TEXT]... -- PROGRAM [ARG...]" - , " vestigia read [--out FILE] [--object TEXT]... FILE.xml" + , " vestigia read [--out FILE] [--object TEXT]... RUN..." , " vestigia record [--qemu PATH] [--plugin PATH] --dump FILE \ \-- PROGRAM [ARG...]" , " vestigia diff [--out FILE] [--addr2line PATH] \ @@ -92,20 +102,25 @@ usage e = do \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)." + , "branch, address, and division that depended on bytes the" + , "program marked secret (vestigia.h), and writes the report" + , "(vestigia/trace-v2); --dir keeps the run in DIR. read reports" + , "on runs kept so, or memcheck XML files made elsewhere, as one." , "valgrind is --valgrind, else $VESTIGIA_VALGRIND, else on PATH." , "--object keeps sites in objects whose path contains TEXT." , "" , "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." + , "dumps its leakage trace per instruction, with how it was run;" + , "diff compares dumps of runs that differ only in a secret and" + , "reports where they diverge (vestigia/diff-v3), naming sources" + , "with addr2line." , "--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." - , "qemu, the plugin, and addr2line default to $VESTIGIA_QEMU," - , "$VESTIGIA_PLUGIN, and $VESTIGIA_ADDR2LINE." + , "QEMU is the one for PROGRAM's architecture: --qemu, else" + , "$VESTIGIA_QEMU_X86_64 or $VESTIGIA_QEMU_AARCH64, else" + , "qemu-x86_64 or qemu-aarch64 on PATH. The plugin and addr2line" + , "default to $VESTIGIA_PLUGIN and $VESTIGIA_ADDR2LINE." ] exitWith (ExitFailure 2) @@ -117,7 +132,7 @@ options = go (Opts Nothing Nothing Nothing Nothing Nothing Nothing 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 + ("--dir" : p : rest) -> go o { oDir = 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 @@ -134,8 +149,12 @@ options = go (Opts Nothing Nothing Nothing Nothing Nothing Nothing -- the settings every trace runs under: origins tracked so a -- secret's descendants are known as such, no limit on contexts, --- the precise definedness checks optimised code needs, and no --- leak report +-- the precise definedness checks optimised code needs, every +-- register (the instruction pointer among them) brought up to date +-- at each instruction of a program's own code, so that a site that +-- touches no memory, a division by a register, is named by its own +-- address rather than the last memory access before it, and no leak +-- report memcheckArgs :: FilePath -> [String] memcheckArgs xml = [ "--tool=memcheck" @@ -145,26 +164,35 @@ memcheckArgs xml = , "--error-limit=no" , "--num-callers=24" , "--expensive-definedness-checks=yes" + , "--px-file-backed=allregs-at-each-insn" , "--leak-check=no" , "--child-silent-after-fork=yes" ] +-- a run kept in a directory (run --dir): memcheck's XML, the objects +-- the harness's first mark wrote (vestigia.h), and the harness's +-- exit status +runXml, runObjects, runExit :: FilePath -> FilePath +runXml d = d </> "memcheck.xml" +runObjects d = d </> "objects" +runExit d = d </> "exit" + run :: Opts -> FilePath -> [String] -> IO () run o prog progArgs = do env <- lookupEnv "VESTIGIA_VALGRIND" let valgrind = maybe (maybe "valgrind" id env) id (oValgrind o) - (xml, temporary) <- case oXml o of - Just p -> pure (p, False) + (xml, objects) <- case oDir o of + Just d -> do + createDirectoryIfMissing True d + writeFile (runObjects d) "" + pure (runXml d, runObjects d) Nothing -> do tmp <- getTemporaryDirectory (p, h) <- openTempFile tmp "vestigia.xml" hClose h - pure (p, True) - -- where the harness's loader put its objects, which the - -- harness's first mark writes (vestigia.h) - tmp <- getTemporaryDirectory - (objects, oh) <- openTempFile tmp "vestigia-objects" - hClose oh + (q, oh) <- openTempFile tmp "vestigia-objects" + hClose oh + pure (p, q) environment <- getEnvironment r <- try (withCreateProcess (proc valgrind (memcheckArgs xml <> (prog : progArgs))) @@ -178,44 +206,79 @@ run o prog progArgs = do <> show (e :: IOException)) exitWith (ExitFailure 2) Right c -> pure c - otext <- TIO.readFile objects - os <- case readObjects otext of + t <- case oDir o of + Just d -> do + writeFile (runExit d) (show (exitStatus code) <> "\n") + readRun d + Nothing -> do + os <- markedObjects objects + mc <- readXml xml + removeFile objects + removeFile xml + pure (Taint "run" mc os (Just (exitStatus code))) + report o [ t ] + +exitStatus :: ExitCode -> Int +exitStatus c = case c of + ExitSuccess -> 0 + ExitFailure n -> n + +-- runs kept by run --dir, or memcheck XML files made elsewhere, of +-- which nothing more is known +readRuns :: Opts -> [FilePath] -> IO () +readRuns o paths = do + ts <- traverse (\p -> do + kept <- doesDirectoryExist p + if kept + then readRun p + else do + mc <- readXml p + pure (Taint (T.pack (takeFileName p)) mc [] Nothing)) + paths + report o ts + +readRun :: FilePath -> IO Taint +readRun d = do + os <- markedObjects (runObjects d) + mc <- readXml (runXml d) + known <- doesFileExist (runExit d) + exit <- if known + then fmap (readMaybe . takeWhile (/= '\n')) (readFile (runExit d)) + else pure Nothing + pure (Taint (T.pack (takeFileName d)) mc os exit) + +-- the objects a run's harness reported where it marked its secret; +-- none written, no secret marked: a run that found nothing because +-- nothing was secret is no evidence, and is refused +markedObjects :: FilePath -> IO [(Integer, T.Text)] +markedObjects p = do + t <- TIO.readFile p + case readObjects t of Left e -> do - hPutStrLn stderr ("vestigia: " <> objects <> ": " <> e) + hPutStrLn stderr ("vestigia: " <> p <> ": " <> e) exitWith (ExitFailure 2) - Right v -> pure v - removeFile objects - -- no objects written, no secret marked: a run that found nothing - -- because nothing was secret is no evidence, and is refused - when (null os) $ do - hPutStrLn stderr "vestigia: the harness marked no secret \ - \(vestigia_secret was never called on any bytes); its run \ - \says nothing" - exitWith (ExitFailure 2) - report o (Just (exitCode code)) os xml - if temporary then removeFile xml else pure () - where - exitCode c = case c of - ExitSuccess -> 0 - ExitFailure n -> n - -readXml :: Opts -> FilePath -> IO () -readXml o xml = report o Nothing [] xml + Right [] -> do + hPutStrLn stderr ("vestigia: " <> p <> ": the harness marked \ + \no secret (vestigia_secret was never called on any bytes); \ + \its run says nothing") + exitWith (ExitFailure 2) + Right os -> pure os -report :: Opts -> Maybe Int -> [(Integer, T.Text)] -> FilePath -> IO () -report o exit os xml = do +readXml :: FilePath -> IO Memcheck +readXml xml = do bytes <- BS.readFile xml - let txt = TE.decodeUtf8With TEE.lenientDecode bytes - case readMemcheck txt of + case readMemcheck (TE.decodeUtf8With TEE.lenientDecode bytes) of Left e -> do hPutStrLn stderr ("vestigia: " <> xml <> ": " <> e) exitWith (ExitFailure 2) - Right mc -> do - let tr = sitesIn (oObjects o) (withObjects os (trace mc)) - doc = encode - (traceJson (T.pack (showVersion version)) exit tr) - maybe (TIO.putStr doc) (\p -> TIO.writeFile p doc) (oOut o) - TIO.hPutStr stderr (traceSummary tr) + Right mc -> pure mc + +report :: Opts -> [Taint] -> IO () +report o ts = do + let tr = sitesIn (oObjects o) (trace ts) + doc = encode (traceJson (T.pack (showVersion version)) 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 @@ -235,7 +298,10 @@ record o prog progArgs = do hPutStrLn stderr "vestigia: address randomisation could not be \ \turned off; runs would differ in layout, and so everywhere" exitWith (ExitFailure 2) - qemu <- tool (oQemu o) "VESTIGIA_QEMU" "qemu-x86_64" + -- QEMU's user mode runs the programs of one architecture + isa <- machineOf prog + qemu <- tool (oQemu o) ("VESTIGIA_QEMU_" <> fmap toUpper isa) + ("qemu-" <> isa) plugin <- tool (oPlugin o) "VESTIGIA_PLUGIN" "libvestigia-qemu.so" -- QEMU maps the guest's libraries and heap in its own address -- space, wherever its own allocations leave room, and those vary @@ -249,22 +315,67 @@ record o prog progArgs = do let tunables = "GLIBC_TUNABLES" host = "glibc.malloc.arena_max=1:glibc.malloc.mmap_threshold=" <> "33554432" + own = lookup tunables environment guest = maybe [ "-U", tunables ] - (\v -> [ "-E", tunables <> "=" <> v ]) (lookup tunables environment) + (\v -> [ "-E", tunables <> "=" <> v ]) own + others = filter ((/= tunables) . fst) environment r <- try (withCreateProcess ((proc qemu (guest <> [ "-seed", "0" , "-plugin", plugin <> ",out=" <> dump, prog ] <> progArgs)) - { P.env = Just ((tunables, host) - : filter ((/= tunables) . fst) environment) }) + { P.env = Just ((tunables, host) : others) }) (\_ _ _ 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 + Right c -> do + -- how the run was made, by which diff tells runs that differ + -- in the secret alone from runs laid out apart: the guest's + -- environment is QEMU's, with its own tunables restored + appendRun dump (prog : progArgs) + (others <> [ (tunables, v) | Just v <- [ own ] ]) c + exitWith c + +-- the architecture an ELF program is built for, as QEMU names its +-- user-mode emulators: the header's machine, at byte 18 +machineOf :: FilePath -> IO String +machineOf prog = do + r <- try (withBinaryFile prog ReadMode (\h -> BS.hGet h 20)) + case fmap BS.unpack r of + Right (0x7f : 0x45 : 0x4c : 0x46 : _ : 1 : rest) + | [ 0x3e, 0 ] <- drop 12 rest -> pure "x86_64" + | [ 0xb7, 0 ] <- drop 12 rest -> pure "aarch64" + Right _ -> unknown "not an x86_64 or aarch64 ELF program" + Left e -> unknown (show (e :: IOException)) + where + unknown why = do + hPutStrLn stderr ("vestigia: " <> prog <> ": " <> why + <> "; QEMU's user mode runs one") + exitWith (ExitFailure 2) + +-- append a run's record to its dump (Vestigia.Diff.readDump): its +-- arguments' bytes, its environment's variables and the bytes they +-- take, and its exit status +appendRun :: FilePath -> [String] -> [(String, String)] -> ExitCode + -> IO () +appendRun dump args env code = do + enc <- getFileSystemEncoding + let bytes s = GF.withCStringLen enc s BS.packCStringLen + as <- traverse bytes args + es <- traverse (\(k, v) -> bytes (k <> "=" <> v)) env + appendFile dump $ unlines $ + [ unwords ("A" : [ concatMap byte (BS.unpack a) | not (BS.null a) ]) + | a <- as ] + <> [ "N " <> showHex (length es) "" <> " " + <> showHex (sum (fmap ((+ 1) . BS.length) es)) "" + , "X " <> show (exitStatus code) ] + where + byte w = case showHex w "" of + [ d ] -> [ '0', d ] + ds -> ds diff :: Opts -> [FilePath] -> IO () diff o paths = do @@ -272,6 +383,28 @@ diff o paths = do dumps <- traverse readOne paths controls <- traverse readOne (oControls o) designed <- traverse readOne (oDesigned o) + -- every run with how it was made: the declared, the designed, then + -- the controls. Runs made apart differ by more than the secret, + -- and are refused, not compared + named <- traverse (\(p, k, d) -> case dpRun d of + Just r -> pure (p, Named (T.pack (takeFileName p)) k r) + Nothing -> refuse (p <> " holds no record of how it was run \ + \(vestigia record appends one), so nothing says what it ran")) + (zip3 (paths <> oDesigned o <> oControls o) + (fmap (const DeclaredRun) paths + <> fmap (const DesignedRun) (oDesigned o) + <> fmap (const ControlRun) (oControls o)) + (dumps <> designed <> controls)) + case named of + ((p0, first) : rest) -> for_ rest $ \(p, n) -> do + for_ (runApart (nmRun first) (nmRun n)) $ \why -> + refuse (p <> " " <> why <> " (" <> p0 <> "); runs compared \ + \must differ in the secret alone") + when (nmKind n == ControlRun + && rnArguments (nmRun n) /= rnArguments (nmRun first)) $ + refuse (p <> " is a control, but does not repeat the \ + \arguments of " <> p0 <> ", the run it controls") + [] -> 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 @@ -291,20 +424,23 @@ diff o paths = do [] -> pure () let dv = restrictTo (oObjects o) (diffDesigned controls dumps designed) - places <- symbolize addr2line + places <- symbolize addr2line $ [ (obj, inOffset i) | (_, i) <- dvOrigins dv , Just obj <- [ inObject i ] ] - -- a run is named by its dump's file name, the declared first - let names = fmap (T.pack . takeFileName) (paths <> oDesigned o) - doc = encode - (divergenceJson (T.pack (showVersion version)) names places dv) + -- the calls that reached each origin + <> [ (obj, a) | (_, i) <- dvOrigins dv + , Just (stacks, _) <- [ M.lookup (inPc i) (dvCallers dv) ] + , calls <- stacks, (Just obj, a) <- calls ] + let doc = encode (divergenceJson (T.pack (showVersion version)) + (fmap snd named) 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) + Left e -> refuse (p <> ": " <> e) Right d -> pure d + refuse e = do + hPutStrLn stderr ("vestigia: " <> e) + exitWith (ExitFailure 2) diff --git a/etc/probe/harness.c b/etc/probe/harness.c @@ -0,0 +1,102 @@ +/* + * harness.c: the harness the flake's probe check runs under both + * instruments. + * + * Copyright (c) 2026 Jared Tobin + * License: MIT + * + * Its secret, the first argument in hex (of one width for every + * secret, so that runs lay out alike), reaches a loop's bound, a + * table index, which of two calls a helper returns to, a dividend, + * a divisor, and, on a path the third secret alone takes, another + * table index; a constant-time select reaches none of them. + */ +#include <stdio.h> +#include <stdlib.h> + +#include "vestigia.h" + +static unsigned char table[256]; +static volatile unsigned modulus = 3329; + +__attribute__((noinline)) unsigned branchy(unsigned s) { + unsigned r = 0; + for (unsigned i = 0; i < (s & 7); i++) r += i; + return r; +} + +__attribute__((noinline)) unsigned lookup(unsigned s) { + return table[s & 0xff]; +} + +__attribute__((noinline)) unsigned leaf(unsigned x) { + return x * 3u + 1u; +} + +/* leaf runs eight times whatever the secret, but returns to one call + or the other as the secret's bits say */ +__attribute__((noinline)) unsigned calls(unsigned s) { + unsigned r = 0; + for (unsigned i = 0; i < 8; i++) { + if ((s >> i) & 1u) r += leaf(i); + else r ^= leaf(i + 1u); + } + return r; +} + +/* a secret divided by a public modulus, the divisor in a register */ +__attribute__((noinline)) unsigned divide(unsigned s) { +#if defined(__x86_64__) + unsigned q, r; + __asm__ volatile("divl %4" + : "=a"(q), "=d"(r) + : "a"(s * 2654435761u), "d"(0u), "r"(modulus)); + return q ^ r; +#else + return (s * 2654435761u) / modulus; +#endif +} + +/* a public value divided by a secret, which x86_64 reads from memory + as the division's operand */ +__attribute__((noinline)) unsigned divide_memory(unsigned s) { + unsigned d = (s & 0xffu) | 1u; +#if defined(__x86_64__) + unsigned q, r; + __asm__ volatile("divl %4" + : "=a"(q), "=d"(r) + : "a"(1000003u), "d"(0u), "m"(d)); + return q ^ r; +#else + return 1000003u / d; +#endif +} + +/* a table index on a path one secret takes */ +__attribute__((noinline)) unsigned rare(unsigned s) { + if ((s & 0xffu) == 0xc3u) return table[(s >> 1) & 0xffu]; + return 0; +} + +__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] : "0", NULL, 16); + unsigned out[7]; + for (int i = 0; i < 256; i++) table[i] = (unsigned char)i; + vestigia_secret(&s, sizeof s); + out[0] = branchy(s); + out[1] = lookup(s); + out[2] = calls(s); + out[3] = divide(s); + out[4] = divide_memory(s); + out[5] = rare(s); + out[6] = ct(s, 1); + vestigia_public(out, sizeof out); + printf("%u %u %u %u %u %u %u\n", out[0], out[1], out[2], out[3], + out[4], out[5], out[6]); + return 0; +} diff --git a/etc/probe/threads.c b/etc/probe/threads.c @@ -0,0 +1,16 @@ +/* + * threads.c: a guest that starts a second thread, which the plugin + * stops rather than record. + * + * Copyright (c) 2026 Jared Tobin + * License: MIT + */ +#include <pthread.h> + +static void *work(void *p) { return p; } + +int main(void) { + pthread_t t; + if (pthread_create(&t, NULL, work, NULL) != 0) return 1; + return pthread_join(t, NULL); +} diff --git a/etc/spike/penumbra/default.nix b/etc/spike/penumbra/default.nix @@ -36,7 +36,7 @@ let traceOf = build: lib: root: vestigia.lib.${system}.traceOf { name = "penumbra-${build}-${root}"; harness = "${harnessFor lib}/bin/harness"; - args = [ root "4" ]; + runs = [ [ root "4" ] ]; }; roots = [ "public_key" "key_agreement" "spend_auth_sign" ]; diff --git a/etc/spike/poly1305/default.nix b/etc/spike/poly1305/default.nix @@ -49,7 +49,7 @@ let path = vestigia.lib.${system}.traceOf { name = "poly1305-${name}"; harness = bin; - args = [ "4" "1" ]; + runs = [ [ "4" "1" ] ]; }; } { name = "diff"; path = vestigia.lib.${system}.diffOf { diff --git a/etc/spike/secp256k1/default.nix b/etc/spike/secp256k1/default.nix @@ -53,7 +53,7 @@ let path = vestigia.lib.${system}.traceOf { name = "secp256k1-${name}"; harness = bin; - args = [ "2" "1" ]; + runs = [ [ "2" "1" ] ]; }; } { name = "diff"; path = vestigia.lib.${system}.diffOf { diff --git a/flake.nix b/flake.nix @@ -35,7 +35,13 @@ inherit (v) traceOf diffOf valgrindDev version; }; - checks.default = hpkgs.vestigia; + checks = { + default = hpkgs.vestigia; + } + # both instruments run Linux programs + // lib.optionalAttrs linux { + probe = import ./nix/probe.nix { inherit pkgs; vestigia = v; }; + }; devShells.default = hpkgs.shellFor { packages = p: [ p.vestigia ]; diff --git a/lib/Vestigia/Diff.hs b/lib/Vestigia/Diff.hs @@ -10,7 +10,8 @@ -- 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. +-- to each target, a fingerprint of the addresses it touched, and, +-- for an integer division, a fingerprint of its operands. -- -- A branch whose outcome did not depend on the secret takes each -- of its transfers in the same proportion however often it runs; a @@ -21,9 +22,14 @@ -- taking each transfer as often, in another order: the plugin keeps -- an order-sensitive fingerprint of what followed each instruction. -- An instruction run equally often in every run whose addresses --- differ computed one from the secret: an `address` origin. Every +-- differ computed one from the secret: an `address` origin. A +-- division, whose time depends on its operands, run equally often +-- on other operands is an `operand` origin. Every -- other instruction run a different number of times sits where --- control had already diverged: it is downstream. +-- control had already diverged: it is downstream. So is a return +-- whose transfers differ, however often it ran: it goes where the +-- call before it sent it, so its outcomes differ only because +-- control had diverged before the call. -- -- Unlike memcheck's taint, this sees what control flow does to -- data as well as what data does: a value chosen by a secret @@ -42,8 +48,9 @@ -- only in the secret, so an instruction's addresses differing in one -- of them says nothing about the secret. A designed run counts toward -- the control origins (a branch whose outcomes shift under it chose --- by the secret) and what was executed, never toward the address --- origins, which only runs alike in all but the secret can show. +-- by the secret) and what was executed, never toward the address or +-- operand origins, which only runs alike in all but the secret can +-- show. -- -- What every run executed, per object, is reported beside: the -- instructions the chosen secrets exercised, and so, against what @@ -52,14 +59,20 @@ -- Each origin names how the runs behaved there: the runs alike at -- it, grouped (taking each transfer in the same proportion, at a -- control origin; touching the same addresses, at an address --- origin), and the runs that never reached it. Whether two runs +-- origin; dividing the same operands, at an operand origin), and the +-- runs that never reached it. Whether two runs -- that differed there both had their secrets accepted is the -- reader's to say, from what each run returned. module Vestigia.Diff ( Insn(..) + , Run(..) , Dump(..) , readDump + , callsOf + , runApart + , Kind(..) + , Named(..) , Origin(..) , Divergence(..) , diffDumps @@ -72,16 +85,22 @@ module Vestigia.Diff ( , divergenceSummary ) where +import Control.Monad (foldM, unless) +import qualified Data.ByteString as BS 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 qualified Data.Text.Encoding as TE +import qualified Data.Text.Encoding.Error as TEE import Numeric (readHex, showHex) +import Text.Read (readMaybe) import Vestigia.Json import Vestigia.Symbol (Place(..)) --- | One instruction of a run. +-- | One instruction of a run: its run-time address, and its object +-- and address there, where the object's program headers place it. data Insn = Insn { inPc :: !Integer , inCount :: !Integer @@ -93,44 +112,173 @@ data Insn = Insn { , inOffset :: !Integer } deriving (Eq, Show) --- | One run: its instructions by address, and its transfers by --- source, each a count per target. +-- | How a run was made, as `vestigia record` appends it to the +-- plugin's dump: the program and its arguments, as the bytes it +-- passed them; the variables of the environment the guest was +-- given, and the bytes they take; and the run's exit status. +data Run = Run { + rnArguments :: ![BS.ByteString] + , rnEnvironment :: !(Integer, Integer) + , rnStatus :: !Integer + } deriving (Eq, Show) + +-- | One run: its instructions by address, its transfers by source, +-- each a count per target, the instructions that return, the +-- divisions' operands, fingerprinted, the contexts calls made (each +-- by the context it was made in and the call's address, 0 being +-- the program's own), the contexts each instruction ran in (with +-- whether it ran in more than the plugin kept), and how the run +-- was made, where its dump says. data Dump = Dump { dpInsns :: !(M.Map Integer Insn) , dpTransfers :: !(M.Map Integer (M.Map Integer Integer)) + , dpReturns :: !(S.Set Integer) + , dpOperands :: !(M.Map Integer T.Text) + , dpContexts :: !(M.Map Integer (Integer, Integer)) + , dpCallers :: !(M.Map Integer (Bool, [Integer])) + , dpRun :: !(Maybe Run) } deriving (Eq, Show) -- | Read a plugin dump. A dump in which no instruction ran records -- no run (the plugin writes every instruction the guest executed), --- and is refused rather than compared as one that did nothing. +-- and is refused rather than compared as one that did nothing, as +-- is the dump of a guest the plugin stopped for starting a second +-- thread, or one whose contexts do not chain back to the +-- 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. readDump :: T.Text -> Either String Dump -readDump t = go (T.lines t) M.empty M.empty +readDump t = do + d <- foldM line (Dump M.empty M.empty S.empty M.empty M.empty M.empty + Nothing, [], Nothing, Nothing) (T.lines t) + let (dump, as, env, st) = d + cx = dpContexts dump + unless (any ((> 0) . inCount) (M.elems (dpInsns dump))) $ + Left "no instruction ran: the run was not recorded" + -- each context made in one made before it, so that its calls end + unless (and [ c' < c && (c' == 0 || M.member c' cx) + | (c, (c', _)) <- M.toList cx ]) $ + Left "a context made in one not made before it" + unless (and [ c == 0 || M.member c cx + | (_, cs) <- M.elems (dpCallers dump), c <- cs ]) $ + Left "an instruction run in a context the dump does not make" + r <- record (reverse as) env st + pure dump { dpRun = r } where - go ls is es = case ls of - [] - | any ((> 0) . inCount) (M.elems is) -> pure (Dump is es) - | otherwise -> - Left "no instruction ran: the run was not recorded" - (l : rest) -> case T.words l of - [ "I", pc, n, a, fp, ord, obj, off ] -> do - i <- Insn <$> unhex pc <*> unhex n <*> unhex a <*> pure fp - <*> pure ord - <*> 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) + line (d, as, env, st) l = case T.words l of + [ "I", pc, n, a, fp, ord, obj, off ] -> do + i <- Insn <$> unhex pc <*> unhex n <*> unhex a <*> pure fp + <*> pure ord + <*> pure (if obj == "-" then Nothing else Just obj) + <*> unhex off + pure (d { dpInsns = M.insert (inPc i) i (dpInsns d) }, as, env + , st) + [ "E", from, to, n ] -> do + f <- unhex from + k <- unhex to + c <- unhex n + pure (d { dpTransfers = M.insertWith M.union f (M.singleton k c) + (dpTransfers d) }, as, env, st) + [ "R", pc ] -> do + r <- unhex pc + pure (d { dpReturns = S.insert r (dpReturns d) }, as, env, st) + [ "D", pc, fp ] -> do + k <- unhex pc + pure (d { dpOperands = M.insert k fp (dpOperands d) }, as, env + , st) + [ "C", c, caller, call ] -> do + v <- (,) <$> unhex caller <*> unhex call + k <- unhex c + pure (d { dpContexts = M.insert k v (dpContexts d) }, as, env + , st) + ("K" : pc : partial : cs@(_ : _)) -> do + k <- unhex pc + p <- case partial of + "0" -> pure False + "1" -> pure True + _ -> Left ("an unreadable dump line: " <> T.unpack l) + xs <- traverse unhex cs + pure (d { dpCallers = M.insert k (p, xs) (dpCallers d) }, as + , env, st) + [ "T", _ ] -> + Left "the guest started a second thread, which a trace \ + \cannot record: the run was stopped" + [ "A" ] -> pure (d, BS.empty : as, env, st) + [ "A", h ] -> do + b <- unhexBytes h + pure (d, b : as, env, st) + [ "N", n, b ] -> do + v <- (,) <$> unhex n <*> unhex b + pure (d, as, Just v, st) + [ "X", x ] -> case readMaybe (T.unpack x) of + Just v -> pure (d, as, env, Just v) + Nothing -> Left ("not an exit status: " <> T.unpack x) + [] -> pure (d, as, env, st) + _ -> Left ("an unreadable dump line: " <> T.unpack l) + record as env st = case (as, env, st) of + ([], Nothing, Nothing) -> pure Nothing + (_ : _, Just e, Just x) -> pure (Just (Run as e x)) + _ -> Left "an incomplete record of the run" unhex x = case readHex (T.unpack x) of [ (v, "") ] -> pure v _ -> Left ("not hexadecimal: " <> T.unpack x) + unhexBytes h + | odd (T.length h) = Left ("not hexadecimal bytes: " <> T.unpack h) + | otherwise = + BS.pack <$> traverse (fmap fromInteger . unhex) (T.chunksOf 2 h) + +-- | The calls that made a context, innermost first, at most 24 of +-- them (as memcheck keeps): each by its object and address there, +-- or by its run-time address where the run names no object for it. +callsOf :: Dump -> Integer -> [(Maybe T.Text, Integer)] +callsOf d = go (24 :: Int) + where + go n c + | n <= 0 || c == 0 = [] + | otherwise = case M.lookup c (dpContexts d) of + Just (caller, pc) -> site pc : go (n - 1) caller + Nothing -> [] + site pc = case M.lookup pc (dpInsns d) of + Just i | Just o <- inObject i -> (Just o, inOffset i) + _ -> (Nothing, pc) + +-- | Why a run cannot be set beside the first as differing from it in +-- the secret alone, if it cannot: it ran another program, its +-- arguments differ from the first run's in number or length, or it +-- was given an environment of another size. Each lays the guest's +-- stack out apart, and so every address on it. +runApart :: Run -> Run -> Maybe String +runApart first r + | take 1 (rnArguments r) /= take 1 (rnArguments first) = + Just "ran another program than the first run" + | fmap BS.length (rnArguments r) + /= fmap BS.length (rnArguments first) = + Just "has arguments that differ from the first run's in number \ + \or length" + | rnEnvironment r /= rnEnvironment first = + Just "was given an environment of another size than the first \ + \run's" + | otherwise = Nothing + +-- | What a run is to a comparison: declared, designed for a path, or +-- a control repeating the first run's secret. +data Kind = + DeclaredRun + | DesignedRun + | ControlRun + deriving (Eq, Show) + +-- | A run as the report names it: by its dump's file name, with what +-- it is to the comparison and how it was made. +data Named = Named { + nmName :: !T.Text + , nmKind :: !Kind + , nmRun :: !Run + } deriving (Eq, Show) -- | Where a run loaded each object it executed: any of its --- instructions' addresses less its offset in the object. +-- instructions' run-time addresses less its address in the object. loadBases :: Dump -> M.Map T.Text Integer loadBases d = M.fromList [ (o, inPc i - inOffset i) @@ -152,6 +300,7 @@ layoutApart a b = data Origin = ControlOrigin | AddressOrigin + | OperandOrigin deriving (Eq, Ord, Show) -- | Where the runs differ: the origins, each an instruction run @@ -161,7 +310,8 @@ data Divergence = Divergence { dvOrigins :: ![(Origin, Insn)] -- | at each origin, the runs alike there, grouped, and the runs -- that never reached it, as indices into the declared runs - -- then the designed (an address origin's, the declared alone) + -- then the designed (an address or operand origin's, the + -- declared alone) , dvBehaviour :: !(M.Map (Origin, Integer) ([[Int]], [Int])) , dvDownstream :: ![Insn] , dvUnsteady :: ![Insn] @@ -170,6 +320,10 @@ data Divergence = Divergence { , dvDesigned :: !Int -- | the offsets some run, control or not, executed, by object , dvExecuted :: !(M.Map T.Text (S.Set Integer)) + -- | at each origin, the distinct chains of calls that reached it + -- in the declared and designed runs, at most 16 of them, and + -- whether there were more than those + , dvCallers :: !(M.Map Integer ([[(Maybe T.Text, Integer)]], Bool)) } deriving (Eq, Show) -- | Compare runs, uncontrolled. @@ -184,8 +338,9 @@ diffControlled :: [Dump] -> [Dump] -> Divergence diffControlled cs ds = diffDesigned cs ds [] -- | Compare runs, given controls and runs designed to reach particular --- paths. The address origins are the declared runs' alone; the --- control origins and what was executed are every run's. +-- paths. The address and operand origins are the declared runs' +-- alone; the control origins and what was executed are every +-- run's. diffDesigned :: [Dump] -> [Dump] -> [Dump] -> Divergence diffDesigned cs ds xs = Divergence { dvOrigins = L.sortOn (inPc . snd) origins @@ -205,8 +360,17 @@ diffDesigned cs ds xs = Divergence { , i <- M.elems (dpInsns d) , inCount i > 0 , Just obj <- [ inObject i ] ] + , dvCallers = M.fromList + [ (inPc i, callersAt (inPc i)) | (_, i) <- origins ] } where + callersAt pc = + let found = [ (partial, fmap (callsOf d) ks) + | d <- runs + , Just (partial, ks) <- [ M.lookup pc (dpCallers d) ] ] + stacks = L.nub (concatMap snd found) + in (take 16 stacks, any fst found || length stacks > 16) + -- what differs between the first run and a repeat of its secret unsteady = case ds of (d : _) -> S.unions (fmap (differing d) cs) @@ -219,10 +383,11 @@ diffDesigned cs ds xs = Divergence { ib = M.lookup pc (dpInsns b) ta = M.lookup pc (dpTransfers a) tb = M.lookup pc (dpTransfers b) - , fmap sig ia /= fmap sig ib || ta /= tb ] + , fmap sig ia /= fmap sig ib || ta /= tb + || M.lookup pc (dpOperands a) /= M.lookup pc (dpOperands b) ] sig i = (inCount i, inFingerprint i, inOrder i) - origins = [ o | o@(_, i) <- controls <> addresses + origins = [ o | o@(_, i) <- controls <> addresses <> operands , not (inPc i `S.member` unsteady) ] -- every run, the declared first @@ -230,7 +395,7 @@ diffDesigned cs ds xs = Divergence { -- how the runs behaved at an origin: those that ran it, grouped -- by what they did there, and those that never ran it. Only the - -- declared runs speak to an address. + -- declared runs speak to an address or an operand. behaviourAt o pc = case o of ControlOrigin -> ( classes outcomesAlike @@ -241,6 +406,10 @@ diffDesigned cs ds xs = Divergence { AddressOrigin -> ( classes (==) [ (k, inFingerprint i) | (k, _, i) <- ranIn ds ] , absentIn ds ) + OperandOrigin -> + ( classes (==) + [ (k, M.lookup pc (dpOperands d)) | (k, d, _) <- ranIn ds ] + , absentIn ds ) where ranIn rs = [ (k, d, i) | (k, d) <- zip [ 0 .. ] rs , Just i <- [ M.lookup pc (dpInsns d) ] @@ -290,10 +459,18 @@ diffDesigned cs ds xs = Divergence { (r : rest) -> not (all (outcomesAlike r) rest) [] -> False - controlPcs = S.filter divergent transferSources - addressPcs = S.fromList [ inPc i | (_, i) <- addresses ] - downstream = ((pcs `S.difference` steady) `S.difference` controlPcs) - `S.difference` addressPcs + -- a return goes where its call sent it: downstream, never an + -- origin, whatever its outcomes + returns = S.unions (fmap dpReturns runs) + divergentPcs = S.filter divergent transferSources + controlPcs = divergentPcs `S.difference` returns + -- the address and operand origins + dataPcs = S.fromList [ inPc i | (_, i) <- addresses <> operands ] + downstream = + (((pcs `S.difference` steady) + `S.union` (divergentPcs `S.intersection` returns)) + `S.difference` controlPcs) + `S.difference` dataPcs controls = [ (ControlOrigin, i) @@ -309,6 +486,16 @@ diffDesigned cs ds xs = Divergence { , Just i <- [ firstInsn pc ] , inAccesses i > 0 ] + -- a division run equally often in every declared run, on other + -- operands in one + operands = + [ (OperandOrigin, i) + | pc <- S.toAscList steadyDeclared + , let fps = [ M.lookup pc (dpOperands d) | d <- ds ] + , any (/= Nothing) fps + , not (alike fps) + , Just i <- [ firstInsn pc ] ] + -- | Only the origins, what was executed, and what ran downstream, in -- objects whose path contains one of the given texts. restrictTo :: [T.Text] -> Divergence -> Divergence @@ -329,22 +516,35 @@ originText :: Origin -> T.Text originText o = case o of ControlOrigin -> "control" AddressOrigin -> "address" + OperandOrigin -> "operand" --- | The report: @vestigia/diff-v2@. Runs are named as given, the --- declared then the designed. +-- | The report: @vestigia/diff-v3@. Runs are given the declared, +-- then the designed, then the controls, and an origin names the +-- 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. divergenceJson - :: T.Text -> [T.Text] -> M.Map (T.Text, Integer) [Place] -> Divergence + :: T.Text -> [Named] -> M.Map (T.Text, Integer) [Place] -> Divergence -> Value -divergenceJson version names places dv = Object - [ ("schema", String "vestigia/diff-v2") +divergenceJson version runs places dv = Object + [ ("schema", String "vestigia/diff-v3") , ("tool", Object [ ("name", String "vestigia") , ("version", String version) ]) - , ("runs", Number (toInteger (dvRuns dv))) - , ("controls", Number (toInteger (dvControls dv))) - , ("designed", Number (toInteger (dvDesigned dv))) + , ("runs", Array + [ Object + [ ("name", String (nmName r)) + , ("kind", String (kindText (nmKind r))) + , ("arguments", Array + [ String (TE.decodeUtf8With TEE.lenientDecode a) + | a <- rnArguments (nmRun r) ]) + , ("status", Number (rnStatus (nmRun r))) + ] + | r <- runs ]) , ("summary", Object [ ("control", count ControlOrigin) , ("address", count AddressOrigin) + , ("operand", count OperandOrigin) , ("downstream", Number (toInteger (length (dvDownstream dv)))) , ("unsteady", Number (toInteger (length (dvUnsteady dv)))) ]) @@ -356,12 +556,19 @@ divergenceJson version names places dv = Object , ("offset", String (hex (inOffset i))) , ("count", Number (inCount i)) , ("frames", Array (fmap placeJson (placesOf i))) + , ("stacks", Array + [ Array (located (inObject i, inOffset i) + <> concatMap located calls) + | calls <- stacks ]) + , ("partial", Bool partial) , ("groups", Array (fmap (Array . fmap named) groups)) , ("absent", Array (fmap named absent)) ] | (o, i) <- dvOrigins dv , let (groups, absent) = M.findWithDefault ([], []) (o, inPc i) - (dvBehaviour dv) ]) + (dvBehaviour dv) + (stacks, partial) = M.findWithDefault ([], False) (inPc i) + (dvCallers dv) ]) , ("downstream", Object [ (obj, Array (fmap (String . hex) (S.toAscList offs))) | (obj, offs) <- M.toList (M.fromListWith S.union @@ -373,6 +580,11 @@ divergenceJson version names places dv = Object | (o, xs) <- M.toList (dvExecuted dv) ]) ] where + names = [ nmName r | r <- runs, nmKind r /= ControlRun ] + kindText k = case k of + DeclaredRun -> "declared" + DesignedRun -> "designed" + ControlRun -> "control" count o = Number (toInteger (length [ () | (o', _) <- dvOrigins dv, o' == o ])) named k = case drop k names of @@ -390,6 +602,21 @@ divergenceJson version names places dv = Object , ("file", maybe Null String (plFile p)) , ("line", maybe Null (Number . toInteger) (plLine p)) ] + -- an instruction's frames, innermost first, each with where the + -- instruction is; one unnamed frame where nothing names it + located (mo, a) = + let at = [ ("object", maybe Null String mo) + , ("offset", String (hex a)) ] + ps = case mo of + Just obj -> M.findWithDefault [] (obj, a) places + Nothing -> [] + in case ps of + [] -> [ Object (at <> [ ("function", Null), ("file", Null) + , ("line", Null) ]) ] + _ -> [ case placeJson p of + Object kvs -> Object (at <> kvs) + other -> other + | p <- ps ] -- | One line per origin, innermost frame first, for a terminal. divergenceSummary diff --git a/lib/Vestigia/Symbol.hs b/lib/Vestigia/Symbol.hs @@ -7,20 +7,26 @@ -- 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. +-- Naming an instruction by its source: an addr2line over an +-- object's debug information, inlined frames included, at the +-- instruction's address in the object (where the object's program +-- headers place it, as its disassembly does). LLVM's names each +-- inlined frame by its own function, and an address no symbol covers +-- by none, where binutils' gives the innermost frame the name of the +-- function it was inlined into, and code without debug information +-- (a GHC program's) the name of whatever function precedes it. LLVM +-- leaves a legacy Rust symbol as an Itanium demangler does, its +-- escapes and hash in place, which are undone here. module Vestigia.Symbol ( Place(..) , symbolize , parseAddr2line + , rustLegacy ) where import Control.Exception (IOException, try) +import Data.Char (chr, isHexDigit) import qualified Data.Map.Strict as M import qualified Data.Text as T import Numeric (readHex, showHex) @@ -36,7 +42,7 @@ data Place = Place { , plLine :: !(Maybe Int) } deriving (Eq, Show) --- | The source of each offset, per object, by one addr2line call +-- | The source of each address, per object, by one addr2line call -- per object. An object addr2line cannot read names nothing. symbolize :: FilePath -> [(T.Text, Integer)] @@ -84,7 +90,7 @@ parseAddr2line = go . T.lines place f loc = let (file, line) = location loc in Place { - plFunction = if f == "??" then Nothing else Just f + plFunction = if f == "??" then Nothing else Just (rustLegacy f) , plFile = file , plLine = line } @@ -98,3 +104,40 @@ parseAddr2line = go . T.lines , case readMaybe (T.unpack n) of Just 0 -> Nothing other -> other ) + +-- | A legacy Rust symbol, as an Itanium demangler leaves it, as +-- rustc-demangle gives it: its hash dropped, each path segment's +-- escapes undone. Any other name is left as it is. +-- +-- >>> rustLegacy "a::_$LT$impl$u20$b..C$GT$::f::h346fee1681a4f34f" +-- "a::<impl b::C>::f" +rustLegacy :: T.Text -> T.Text +rustLegacy t = case T.breakOnEnd "::" t of + (pre, h) | not (T.null pre), hash h -> + T.intercalate "::" + (fmap segment (T.splitOn "::" (T.dropEnd 2 pre))) + _ -> t + where + hash h = case T.uncons h of + Just ('h', ds) -> T.length ds == 16 && T.all isHexDigit ds + _ -> False + segment g = T.pack (unescape (T.unpack + (maybe g ("$" <>) (T.stripPrefix "_$" g)))) + unescape cs = case cs of + '$' : rest + | (code, '$' : more) <- break (== '$') rest + , Just r <- escape code -> r <> unescape more + '.' : '.' : more -> "::" <> unescape more + c : more -> c : unescape more + [] -> [] + escape code = case code of + "SP" -> Just "@" + "BP" -> Just "*" + "RF" -> Just "&" + "LT" -> Just "<" + "GT" -> Just ">" + "LP" -> Just "(" + "RP" -> Just ")" + "C" -> Just "," + 'u' : h | [ (n, "") ] <- readHex h -> Just [ chr n ] + _ -> Nothing diff --git a/lib/Vestigia/Trace.hs b/lib/Vestigia/Trace.hs @@ -11,19 +11,27 @@ -- whose undefined value was created by a client request descends -- from a byte the harness marked secret, so it is a site where the -- execution depended on the secret: a branch (`control`), an --- address computed from it (`address`), or a system call handed --- it (`syscall`). Every other uninitialised read is kept apart as --- `unrelated`, and memory errors as `faults`: neither says --- anything about the secret. Leak reports say nothing about the --- execution and are dropped. +-- address computed from it (`address`), an integer division's +-- operand (`operand`, from vestigia's patched memcheck), or a +-- system call handed it (`syscall`). Every other uninitialised read +-- is kept apart as `unrelated`, and memory errors as `faults`: +-- neither says anything about the secret. Leak reports say nothing +-- about the execution and are dropped. +-- +-- memcheck sees the path a run took, so runs of one harness on +-- several secrets are read together: a site is one instruction and +-- class however many runs reached it, with the runs that did. Where +-- a run's objects are known, a site is named by its address in its +-- object, so runs that loaded an object apart still agree on it. module Vestigia.Trace ( Class(..) + , Taint(..) + , Located(..) , Site(..) , Trace(..) , trace , sitesIn - , withObjects , readObjects , objectOffset , traceJson @@ -43,34 +51,55 @@ import Vestigia.Memcheck data Class = Control | Address + | Operand | Syscall deriving (Eq, Ord, Show) --- | One instruction that depended on a secret, with every stack --- that reached it. +-- | One run under memcheck: its name, what memcheck reported, where +-- the harness's loader put each object (its load bias and path, +-- as the harness reported them; none for a report made +-- elsewhere), and the harness's exit status, where known. +data Taint = Taint { + tnName :: !T.Text + , tnMemcheck :: !Memcheck + , tnObjects :: ![(Integer, T.Text)] + , tnExit :: !(Maybe Int) + } deriving (Eq, Show) + +-- | A frame, with its address in its object where its run's objects +-- say. +data Located = Located { + lcFrame :: !Frame + , lcOffset :: !(Maybe Integer) + } deriving (Eq, Show) + +-- | One instruction that depended on a secret: where, as the first +-- run to reach it saw it; how many times, over every run; the runs +-- that reached it, as indices into the runs; and every stack that +-- did. data Site = Site { siClass :: !Class - , siFrame :: !Frame + , siAt :: !Located , siCount :: !Int - , siStacks :: ![[Frame]] + , siRuns :: ![Int] + , siStacks :: ![[Located]] } deriving (Eq, Show) --- | A run's sites, in the order first reported, and what memcheck --- reported that says nothing about the secret. +-- | Runs' sites, in the order first reported, and what memcheck +-- reported that says nothing about the secret, each with the +-- index of the run that reported it. data Trace = Trace { - trRun :: !Memcheck + trRuns :: ![Taint] , trSites :: ![Site] - , trUnrelated :: ![MemError] - , trFaults :: ![MemError] - -- | where the harness's loader put each object: its load bias - -- and path, as the harness reported them - , trObjects :: ![(Integer, T.Text)] + , trUnrelated :: ![(Int, MemError)] + , trFaults :: ![(Int, MemError)] } deriving (Eq, Show) classText :: Class -> T.Text classText c = case c of Control -> "control" Address -> "address" + Operand -> "operand" Syscall -> "syscall" -- what a secret-originated error says about the execution @@ -78,46 +107,59 @@ classify :: MemError -> Maybe Class classify e = case (meOrigin e, meKind e) of (ClientRequest _, "UninitCondition") -> Just Control (ClientRequest _, "UninitValue") -> Just Address + (ClientRequest _, "UninitOperand") -> Just Operand (ClientRequest _, "SyscallParam") -> Just Syscall _ -> Nothing uninitialised :: MemError -> Bool -uninitialised e = - meKind e `elem` [ "UninitCondition", "UninitValue", "SyscallParam" ] +uninitialised e = meKind e `elem` + [ "UninitCondition", "UninitValue", "UninitOperand", "SyscallParam" ] --- | The run's leakage. Sites are one per instruction and class: +-- | The runs' leakage. Sites are one per instruction and class: -- memcheck reports one context per distinct stack, and the --- instruction is what depended on the secret, whatever called --- it. -trace :: Memcheck -> Trace -trace mc = Trace { - trRun = mc +-- instruction is what depended on the secret, whatever called it +-- and in whichever run. +trace :: [Taint] -> Trace +trace ts = Trace { + trRuns = ts , trSites = [ Site { siClass = c - , siFrame = top - , siCount = sum (fmap (meCount . snd) xs) - , siStacks = L.nub (fmap (meStack . snd) xs) + , siAt = at + , siCount = sum [ meCount e | (_, e, _) <- xs ] + , siRuns = L.nub [ k | (k, _, _) <- xs ] + , siStacks = L.nubBy (\a b -> fmap ident a == fmap ident b) + [ st | (_, _, st) <- xs ] } - | k@(c, _) <- order - , Just xs@((top, _) : _) <- [ M.lookup k grouped ] ] + | key@(c, _) <- L.nub (fmap fst tagged) + , Just xs@((_, _, at : _) : _) <- [ M.lookup key grouped ] ] , trUnrelated = - [ e | e <- mcErrors mc, uninitialised e - , classify e == Nothing ] + [ (k, e) | (k, t) <- runs, e <- mcErrors (tnMemcheck t) + , uninitialised e, classify e == Nothing ] , trFaults = - [ e | e <- mcErrors mc, not (uninitialised e) - , not ("Leak_" `T.isPrefixOf` meKind e) ] - , trObjects = [] + [ (k, e) | (k, t) <- runs, e <- mcErrors (tnMemcheck t) + , not (uninitialised e) + , not ("Leak_" `T.isPrefixOf` meKind e) ] } where + runs = zip [ 0 .. ] ts tagged = - [ ((c, top), e) - | e <- mcErrors mc + [ ((c, ident top), (k, e, st)) + | (k, t) <- runs + , e <- mcErrors (tnMemcheck t) , Just c <- [ classify e ] - , (top : _) <- [ meStack e ] ] - order = L.nub (fmap (\((c, top), _) -> (c, frIp top)) tagged) + , let st = fmap (locate (tnObjects t)) (meStack e) + , (top : _) <- [ st ] ] grouped = M.fromListWith (flip (<>)) - [ ((c, frIp top), [ (top, e) ]) | ((c, top), e) <- tagged ] + [ (key, [ v ]) | (key, v) <- tagged ] + -- an instruction is its address in its object where that is + -- known, else its address in the run + ident (Located f off) = + ( frObj f, maybe (Left (frIp f)) Right off, frFn f, frFile f + , frLine f ) + +locate :: [(Integer, T.Text)] -> Frame -> Located +locate os f = Located f (objectOffset os f) -- | Only the sites in objects whose path contains one of the given -- texts, as a differential run keeps its origins; every site when @@ -127,15 +169,10 @@ sitesIn ts tr | null ts = tr | otherwise = tr { trSites = filter inside (trSites tr) } where - inside s = case frObj (siFrame s) of + inside s = case frObj (lcFrame (siAt s)) of Just o -> any (`T.isInfixOf` o) ts Nothing -> False --- | The trace with the harness's map of its objects, by which each --- frame is named by its address in its object. -withObjects :: [(Integer, T.Text)] -> Trace -> Trace -withObjects os tr = tr { trObjects = os } - -- | The objects file the harness's first mark writes: a load bias -- (hex) and a path per line. -- @@ -166,65 +203,71 @@ objectOffset os f = do (x : _) -> x [] -> p --- | The report: @vestigia/trace-v1@. -traceJson :: T.Text -> Maybe Int -> Trace -> Value -traceJson version exit tr = Object - [ ("schema", String "vestigia/trace-v1") +-- | The report: @vestigia/trace-v2@. +traceJson :: T.Text -> Trace -> Value +traceJson version tr = Object + [ ("schema", String "vestigia/trace-v2") , ("tool", Object [ ("name", String "vestigia") , ("version", String version) ]) - , ("valgrind", maybe Null String (mcVersion mc)) - , ("command", Array (fmap String (mcCommand mc))) - , ("finished", Bool (mcFinished mc)) - , ("exit", maybe Null (Number . toInteger) exit) + , ("runs", Array + [ Object + [ ("name", String (tnName t)) + , ("valgrind", maybe Null String (mcVersion mc)) + , ("command", Array (fmap String (mcCommand mc))) + , ("finished", Bool (mcFinished mc)) + , ("exit", maybe Null (Number . toInteger) (tnExit t)) + , ("objects", Array + [ Object [ ("base", String (hex b)), ("path", String p) ] + | (b, p) <- tnObjects t ]) + ] + | t <- trRuns tr, let mc = tnMemcheck t ]) , ("summary", Object [ ("control", count Control) , ("address", count Address) + , ("operand", count Operand) , ("syscall", count Syscall) , ("unrelated", Number (toInteger (length (trUnrelated tr)))) , ("faults", Number (toInteger (length (trFaults tr)))) ]) - , ("objects", Array - [ Object [ ("base", String (hex b)), ("path", String p) ] - | (b, p) <- trObjects tr ]) - , ("sites", Array (fmap (siteJson os) (trSites tr))) - , ("unrelated", Array (fmap (errorJson os) (trUnrelated tr))) - , ("faults", Array (fmap (errorJson os) (trFaults tr))) + , ("sites", Array (fmap siteJson (trSites tr))) + , ("unrelated", Array (fmap errorJson (trUnrelated tr))) + , ("faults", Array (fmap errorJson (trFaults tr))) ] where - os = trObjects tr - mc = trRun tr count c = Number (toInteger (length [ () | s <- trSites tr, siClass s == c ])) + name k = case drop k (trRuns tr) of + (t : _) -> String (tnName t) + [] -> String (T.pack (show k)) + objectsOf k = case drop k (trRuns tr) of + (t : _) -> tnObjects t + [] -> [] + siteJson s = Object $ + [ ("class", String (classText (siClass s))) ] + <> locatedFields (siAt s) + <> [ ("count", Number (toInteger (siCount s))) + , ("runs", Array (fmap name (siRuns s))) + , ("stacks", Array [ Array (fmap (Object . locatedFields) st) + | st <- siStacks s ]) + ] + errorJson (k, e) = Object + [ ("run", name k) + , ("kind", String (meKind e)) + , ("what", String (meWhat e)) + , ("origin", case meOrigin e of + ClientRequest _ -> String "client request" + OtherOrigin w _ -> String w + NoOrigin -> Null) + , ("count", Number (toInteger (meCount e))) + , ("stack", Array (fmap (Object . locatedFields + . locate (objectsOf k)) (meStack e))) + ] -siteJson :: [(Integer, T.Text)] -> Site -> Value -siteJson os s = Object $ - [ ("class", String (classText (siClass s))) ] - <> frameFields os (siFrame s) - <> [ ("count", Number (toInteger (siCount s))) - , ("stacks", Array [ Array (fmap (frameJson os) st) - | st <- siStacks s ]) - ] - -errorJson :: [(Integer, T.Text)] -> MemError -> Value -errorJson os e = Object - [ ("kind", String (meKind e)) - , ("what", String (meWhat e)) - , ("origin", case meOrigin e of - ClientRequest _ -> String "client request" - OtherOrigin w _ -> String w - NoOrigin -> Null) - , ("count", Number (toInteger (meCount e))) - , ("stack", Array (fmap (frameJson os) (meStack e))) - ] - -frameJson :: [(Integer, T.Text)] -> Frame -> Value -frameJson os = Object . frameFields os - -frameFields :: [(Integer, T.Text)] -> Frame -> [(T.Text, Value)] -frameFields os f = +locatedFields :: Located -> [(T.Text, Value)] +locatedFields (Located f off) = [ ("ip", String (hex (frIp f))) , ("object", maybe Null String (frObj f)) - , ("offset", maybe Null (String . hex) (objectOffset os f)) + , ("offset", maybe Null (String . hex) off) , ("function", maybe Null String (frFn f)) , ("file", maybe Null String (frFile f)) , ("line", maybe Null (Number . toInteger) (frLine f)) @@ -233,21 +276,27 @@ frameFields os f = hex :: Integer -> T.Text hex n = "0x" <> T.pack (showHex n "") --- | One line per site, for a reader at a terminal. +-- | One line per site, for a reader at a terminal: with how many runs +-- reached it, where there were several. traceSummary :: Trace -> T.Text traceSummary tr = T.unlines $ - [ T.intercalate " " + [ T.intercalate " " $ [ classText (siClass s) - , hex (frIp (siFrame s)) - , maybe "?" id (frFn (siFrame s)) - , location (siFrame s) + , hex (frIp f) + , maybe "?" id (frFn f) + , location f , "x" <> T.pack (show (siCount s)) ] - | s <- trSites tr ] + <> [ T.pack (show (length (siRuns s))) <> "/" + <> T.pack (show runs) <> " runs" + | runs > 1 ] + | s <- trSites tr, let f = lcFrame (siAt s) ] <> [ T.pack (show (length (trSites tr))) <> " secret-dependent \ - \site(s); " <> T.pack (show (length (trUnrelated tr))) + \site(s) over " <> T.pack (show runs) <> " run(s); " + <> T.pack (show (length (trUnrelated tr))) <> " unrelated; " <> T.pack (show (length (trFaults tr))) <> " fault(s)" ] where + runs = length (trRuns tr) location f = case (frFile f, frLine f) of (Just p, Just l) -> base p <> ":" <> T.pack (show l) (Just p, Nothing) -> base p diff --git a/nix/lib.nix b/nix/lib.nix @@ -16,24 +16,24 @@ let }); # memcheck runs on Linux only; elsewhere the command can still - # read a run's XML + # read a run's XML. It reports an integer division's undefined + # operands (memcheck-operand.patch) linux = pkgs.stdenv.hostPlatform.isLinux; - valgrind = pkgs.valgrind; + valgrind = pkgs.valgrind.overrideAttrs (o: { + patches = (o.patches or [ ]) ++ [ ./memcheck-operand.patch ]; + }); # 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: user mode alone, its guest's getrandom - # drawn from the generator -seed fixes (qemu-getrandom.patch) + # 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 = (pkgs.qemu.override { userOnly = true; hostCpuTargets = [ "x86_64-linux-user" "aarch64-linux-user" ]; }).overrideAttrs (o: { patches = (o.patches or [ ]) ++ [ ./qemu-getrandom.patch ]; }); - 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 ]; @@ -43,9 +43,6 @@ let $(pkg-config --cflags glib-2.0) -I${qemu}/include \ -o $out/lib/libvestigia-qemu.so ${src}/plugin/vestigia-qemu.c ''; - isa = builtins.head - (lib.splitString "-" pkgs.stdenv.hostPlatform.system); - # the harness-side header; a harness build also needs # valgrind's own (valgrindDev) include = pkgs.runCommand "vestigia-include" { } '' @@ -54,7 +51,9 @@ let cp ${src}/include/vestigia.c $out/include/vestigia.c ''; - # the command, with the valgrind it was built against baked in + # the command, with the valgrind it was built against baked in, + # and LLVM's addr2line, which reads either ISA's objects and names + # inlined frames by their own functions (Vestigia.Symbol) vestigia = pkgs.runCommand "vestigia" { nativeBuildInputs = [ pkgs.makeWrapper ]; } '' @@ -62,37 +61,46 @@ let makeWrapper ${hpkgs.vestigia}/bin/vestigia $out/bin/vestigia \ ${lib.optionalString linux (lib.concatStringsSep " " [ "--set-default VESTIGIA_VALGRIND ${valgrind}/bin/valgrind" - "--set-default VESTIGIA_QEMU ${qemuFor.${isa}}" + "--set-default VESTIGIA_QEMU_X86_64 ${qemu}/bin/qemu-x86_64" + "--set-default VESTIGIA_QEMU_AARCH64 ${qemu}/bin/qemu-aarch64" "--set-default VESTIGIA_PLUGIN" "${plugin}/lib/libvestigia-qemu.so" "--set-default VESTIGIA_ADDR2LINE" - "${pkgs.binutils-unwrapped}/bin/addr2line" + "${pkgs.llvm}/bin/llvm-addr2line" ])} ln -s ${include}/include $out/include ''; - # one harness run under memcheck, as a derivation: its report, - # memcheck's XML, and what the harness printed. `objects` keeps - # sites in objects whose path contains one of them. A harness that - # fails still leaves a report of what ran; a run vestigia could - # not read fails the build. - traceOf = { name, harness, args ? [ ], objects ? [ ] }: + # runs of one harness under memcheck, as a derivation: each run + # kept (run-K: memcheck's XML, the objects its harness reported, + # its exit status, its own report), what each printed, and the + # report over all of them. `runs` is a list of argument lists, the + # secrets of the trace; memcheck sees only the path a run takes, so + # each run adds the sites on its own. `objects` keeps sites in + # objects whose path contains one of them. A harness that fails + # still leaves a report of what ran; a run vestigia could not read + # fails the build. + traceOf = { name, harness, runs, objects ? [ ] }: 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 \ + ${lib.concatImapStrings (k: args: '' + set +e + ${vestigia}/bin/vestigia run --dir $out/run-${toString k} \ + --out $out/run-${toString k}/trace.json \ + -- ${harness} ${lib.escapeShellArgs args} \ + > $out/stdout-${toString k} 2> $out/stderr-${toString k} + echo $? > $out/status-${toString k} + set -e + '') runs} + ${vestigia}/bin/vestigia read --out $out/trace.json \ ${lib.concatMapStrings (o: "--object ${lib.escapeShellArg o} ") - objects}-- ${harness} ${lib.escapeShellArgs args} \ - > $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 + objects}${lib.concatImapStrings (k: _: + "$out/run-${toString k} ") runs} 2> $out/summary || { + cat $out/summary >&2 exit 1 } + echo $(( ($(date +%s%N) - start) / 1000000 )) > $out/milliseconds ''; # runs of one harness that differ only in a secret, each diff --git a/nix/memcheck-operand.patch b/nix/memcheck-operand.patch @@ -0,0 +1,316 @@ +memcheck lets an undefined operand flow into a division's quotient +without a word: it reports an undefined value only where it decides a +branch or forms an address. An integer division takes a time that +depends on its operands, so a division of or by a secret leaks it, +and a taint trace should say so. + +This adds an error kind, UninitOperand ("Integer division depends on +uninitialised value(s)", suppressed as "Operand"), with its origin +tracked as a condition's is, and reports it for each undefined +operand of every integer division the guest's code is lifted to: +DivModU/S128to64, 64to32, 64to64, and 32to32 (x86_64's DIV and IDIV +among them), DivU/S32 and 64 and their E forms (aarch64's UDIV and +SDIV), and the 128-bit divisions and modulos. Unlike the checks +before an address or a branch, it leaves the operands undefined +after reporting them, so the quotient's undefinedness, and what is +reported downstream of it, is as it would have been. + +--- a/memcheck/mc_errors.c ++++ b/memcheck/mc_errors.c +@@ -63,6 +63,7 @@ + enum { + Err_Value, + Err_Cond, ++ Err_Operand, + Err_CoreMem, + Err_Addr, + Err_Jump, +@@ -108,6 +109,14 @@ + ExeContext* origin_ec; // filled in later + } Cond; + ++ // Use of an undefined value as an operand of an integer ++ // division, whose time depends on its operands. ++ struct { ++ // Origin info ++ UInt otag; // origin tag ++ ExeContext* origin_ec; // filled in later ++ } Operand; ++ + // Addressability error in core (signal-handling) operation. + // It would be good to get rid of this error kind, merge it with + // another one somehow. +@@ -527,6 +536,26 @@ + } + break; + ++ case Err_Operand: ++ MC_(any_value_errors) = True; ++ if (xml) { ++ emit( " <kind>UninitOperand</kind>\n" ); ++ emit( " <what>Integer division depends" ++ " on uninitialised value(s)</what>\n" ); ++ VG_(pp_ExeContext)( VG_(get_error_where)(err) ); ++ if (extra->Err.Operand.origin_ec) ++ mc_pp_origin( extra->Err.Operand.origin_ec, ++ extra->Err.Operand.otag & 3 ); ++ } else { ++ emit( "Integer division depends" ++ " on uninitialised value(s)\n" ); ++ VG_(pp_ExeContext)( VG_(get_error_where)(err) ); ++ if (extra->Err.Operand.origin_ec) ++ mc_pp_origin( extra->Err.Operand.origin_ec, ++ extra->Err.Operand.otag & 3 ); ++ } ++ break; ++ + case Err_RegParam: + MC_(any_value_errors) = True; + if (xml) { +@@ -936,6 +965,18 @@ + VG_(maybe_record_error)( tid, Err_Cond, /*addr*/0, /*s*/NULL, &extra ); + } + ++void MC_(record_operand_error) ( ThreadId tid, UInt otag ) ++{ ++ MC_Error extra; ++ tl_assert( MC_(clo_mc_level) >= 2 ); ++ if (otag > 0) ++ tl_assert( MC_(clo_mc_level) == 3 ); ++ extra.Err.Operand.otag = otag; ++ extra.Err.Operand.origin_ec = NULL; /* Filled in later */ ++ VG_(maybe_record_error)( tid, Err_Operand, /*addr*/0, /*s*/NULL, ++ &extra ); ++} ++ + /* --- Called from non-generated code --- */ + + /* This is for memory errors in signal-related memory. */ +@@ -1214,6 +1255,7 @@ + case Err_IllegalMempool: + case Err_Overlap: + case Err_Cond: ++ case Err_Operand: + case Err_ReallocSizeZero: + case Err_UnsafeZeroSize: + return True; +@@ -1423,6 +1465,10 @@ + update_origin( &extra->Err.Cond.origin_ec, + extra->Err.Cond.otag ); + return sizeof(MC_Error); ++ case Err_Operand: ++ update_origin( &extra->Err.Operand.origin_ec, ++ extra->Err.Operand.otag ); ++ return sizeof(MC_Error); + case Err_RegParam: + update_origin( &extra->Err.RegParam.origin_ec, + extra->Err.RegParam.otag ); +@@ -1557,6 +1603,9 @@ + // Undefined value error in conditional. + CondSupp, + ++ // Undefined value error in an integer division's operand. ++ OperandSupp, ++ + // Unaddressable read/write attempt at given size + Addr1Supp, Addr2Supp, Addr4Supp, Addr8Supp, Addr16Supp, Addr32Supp, + +@@ -1593,6 +1642,7 @@ + else if (VG_STREQ(name, "Overlap")) skind = OverlapSupp; + else if (VG_STREQ(name, "Mempool")) skind = MempoolSupp; + else if (VG_STREQ(name, "Cond")) skind = CondSupp; ++ else if (VG_STREQ(name, "Operand")) skind = OperandSupp; + else if (VG_STREQ(name, "Value0")) skind = CondSupp; /* backwards compat */ + else if (VG_STREQ(name, "Value1")) skind = Value1Supp; + else if (VG_STREQ(name, "Value2")) skind = Value2Supp; +@@ -1739,6 +1789,9 @@ + case CondSupp: + return (ekind == Err_Cond); + ++ case OperandSupp: ++ return (ekind == Err_Operand); ++ + case Addr1Supp: su_szB = 1; goto addr_case; + case Addr2Supp: su_szB = 2; goto addr_case; + case Addr4Supp: su_szB = 4; goto addr_case; +@@ -1822,6 +1875,7 @@ + case Err_Overlap: return "Overlap"; + case Err_Leak: return "Leak"; + case Err_Cond: return "Cond"; ++ case Err_Operand: return "Operand"; + case Err_FishyValue: return "FishyValue"; + case Err_ReallocSizeZero: return "ReallocZero"; + case Err_BadAlign: return "BadAlign"; +--- a/memcheck/mc_include.h ++++ b/memcheck/mc_include.h +@@ -551,6 +551,7 @@ + void MC_(record_address_error) ( ThreadId tid, Addr a, Int szB, + Bool isWrite ); + void MC_(record_cond_error) ( ThreadId tid, UInt otag ); ++void MC_(record_operand_error) ( ThreadId tid, UInt otag ); + void MC_(record_value_error) ( ThreadId tid, Int szB, UInt otag ); + void MC_(record_jump_error) ( ThreadId tid, Addr a ); + +@@ -773,6 +774,7 @@ + VG_REGPARM(1) void MC_(helperc_value_check4_fail_w_o) ( UWord ); + VG_REGPARM(1) void MC_(helperc_value_check1_fail_w_o) ( UWord ); + VG_REGPARM(1) void MC_(helperc_value_check0_fail_w_o) ( UWord ); ++VG_REGPARM(1) void MC_(helperc_operand_check_fail_w_o) ( UWord ); + + /* And call these ones instead to report an uninitialised value error + but with no origin available. */ +@@ -781,6 +783,7 @@ + VG_REGPARM(0) void MC_(helperc_value_check4_fail_no_o) ( void ); + VG_REGPARM(0) void MC_(helperc_value_check1_fail_no_o) ( void ); + VG_REGPARM(0) void MC_(helperc_value_check0_fail_no_o) ( void ); ++VG_REGPARM(0) void MC_(helperc_operand_check_fail_no_o) ( void ); + + /* V-bits load/store helpers */ + VG_REGPARM(1) void MC_(helperc_STOREV64be) ( Addr, ULong ); +--- a/memcheck/mc_main.c ++++ b/memcheck/mc_main.c +@@ -5751,6 +5751,11 @@ + } + + VG_REGPARM(1) ++void MC_(helperc_operand_check_fail_w_o) ( UWord origin ) { ++ MC_(record_operand_error) ( VG_(get_running_tid)(), (UInt)origin ); ++} ++ ++VG_REGPARM(1) + void MC_(helperc_value_check1_fail_w_o) ( UWord origin ) { + MC_(record_value_error) ( VG_(get_running_tid)(), 1, (UInt)origin ); + } +@@ -5775,6 +5780,11 @@ + VG_REGPARM(0) + void MC_(helperc_value_check0_fail_no_o) ( void ) { + MC_(record_cond_error) ( VG_(get_running_tid)(), 0/*origin*/ ); ++} ++ ++VG_REGPARM(0) ++void MC_(helperc_operand_check_fail_no_o) ( void ) { ++ MC_(record_operand_error) ( VG_(get_running_tid)(), 0/*origin*/ ); + } + + VG_REGPARM(0) +--- a/memcheck/mc_translate.c ++++ b/memcheck/mc_translate.c +@@ -1772,6 +1772,57 @@ + } + + ++/* Check the supplied *original* atoms, the operands of an integer ++ division, for undefinedness, and emit a complaint for each that is ++ undefined. A division takes a time that depends on its operands, ++ so an undefined operand is reported as its own kind of error ++ (Err_Operand), apart from a value used as an address or a ++ condition. Unlike complainIfUndefined, the atoms are not then ++ marked defined: the quotient carries its operands' undefinedness ++ on, as it would had nothing been reported. */ ++static void complainIfOperandsUndefined ( MCEnv* mce, ++ IRAtom* atom1, IRAtom* atom2 ) ++{ ++ IRAtom* atoms[2] = { atom1, atom2 }; ++ Int k; ++ ++ if (MC_(clo_mc_level) == 1) ++ return; ++ ++ for (k = 0; k < 2; k++) { ++ IRAtom* vatom; ++ IRAtom* origin; ++ IRDirty* di; ++ ++ tl_assert(isOriginalAtom(mce, atoms[k])); ++ vatom = expr2vbits( mce, atoms[k], HuOth ); ++ tl_assert(isShadowAtom(mce, vatom)); ++ ++ if (MC_(clo_mc_level) == 3) { ++ origin = schemeE( mce, atoms[k] ); ++ if (mce->hWordTy == Ity_I64) { ++ origin = assignNew( 'B', mce, Ity_I64, ++ unop(Iop_32Uto64, origin) ); ++ } ++ di = unsafeIRDirty_0_N( ++ 1, "MC_(helperc_operand_check_fail_w_o)", ++ VG_(fnptr_to_fnentry)( ++ &MC_(helperc_operand_check_fail_w_o) ), ++ mkIRExprVec_1(origin) ); ++ } else { ++ di = unsafeIRDirty_0_N( ++ 0, "MC_(helperc_operand_check_fail_no_o)", ++ VG_(fnptr_to_fnentry)( ++ &MC_(helperc_operand_check_fail_no_o) ), ++ mkIRExprVec_0() ); ++ } ++ di->guard = mkPCastTo( mce, Ity_I1, vatom ); ++ setHelperAnns( mce, di ); ++ stmt( 'V', mce, IRStmt_Dirty(di) ); ++ } ++} ++ ++ + /*------------------------------------------------------------*/ + /*--- Shadowing PUTs/GETs, and indexed variants thereof ---*/ + /*------------------------------------------------------------*/ +@@ -4153,6 +4204,7 @@ + case Iop_ModU128: + case Iop_ModS128: + /* I128 x I128 -> I128 */ ++ complainIfOperandsUndefined(mce, atom1, atom2); + return mkLazy2(mce, Ity_V128, vatom1, vatom2); + + case Iop_QNarrowBin64Sto32Sx4: +@@ -4687,10 +4739,12 @@ + + case Iop_DivModU64to32: + case Iop_DivModS64to32: ++ complainIfOperandsUndefined(mce, atom1, atom2); + return mkLazy2(mce, Ity_I64, vatom1, vatom2); + + case Iop_DivModU128to64: + case Iop_DivModS128to64: ++ complainIfOperandsUndefined(mce, atom1, atom2); + return mkLazy2(mce, Ity_I128, vatom1, vatom2); + + case Iop_8HLto16: +@@ -4703,6 +4757,7 @@ + case Iop_DivModU64to64: + case Iop_DivModS64to64: { + IRAtom* vTmp64 = mkLazy2(mce, Ity_I64, vatom1, vatom2); ++ complainIfOperandsUndefined(mce, atom1, atom2); + return assignNew('V', mce, Ity_I128, + binop(Iop_64HLto128, vTmp64, vTmp64)); + } +@@ -4718,6 +4773,7 @@ + case Iop_DivModU32to32: + case Iop_DivModS32to32: { + IRAtom* vTmp32 = mkLazy2(mce, Ity_I32, vatom1, vatom2); ++ complainIfOperandsUndefined(mce, atom1, atom2); + return assignNew('V', mce, Ity_I64, + binop(Iop_32HLto64, vTmp32, vTmp32)); + } +@@ -4745,11 +4801,14 @@ + return assignNew('V', mce, Ity_I16, binop(Iop_8HLto16, vHi8, vLo8)); + } + +- case Iop_Sad8Ux4: /* maybe we could do better? ftm, do mkLazy2. */ + case Iop_DivS32: + case Iop_DivU32: + case Iop_DivU32E: + case Iop_DivS32E: ++ complainIfOperandsUndefined(mce, atom1, atom2); ++ return mkLazy2(mce, Ity_I32, vatom1, vatom2); ++ ++ case Iop_Sad8Ux4: /* maybe we could do better? ftm, do mkLazy2. */ + case Iop_QAdd32S: /* could probably do better */ + case Iop_QSub32S: /* could probably do better */ + return mkLazy2(mce, Ity_I32, vatom1, vatom2); +@@ -4758,6 +4817,7 @@ + case Iop_DivU64: + case Iop_DivS64E: + case Iop_DivU64E: ++ complainIfOperandsUndefined(mce, atom1, atom2); + return mkLazy2(mce, Ity_I64, vatom1, vatom2); + + case Iop_Add32: diff --git a/nix/probe.nix b/nix/probe.nix @@ -0,0 +1,146 @@ +# The probe check: etc/probe/harness.c under both instruments, each +# site and origin it should show found where it should be, and +# nothing else in the harness. On an x86_64 host, the same harness +# built for aarch64 is traced differentially too, under QEMU; memcheck +# needs a native host. +{ pkgs, vestigia }: + +let + lib = pkgs.lib; + + # the harness, built by a package set's C compiler and linked at a + # fixed address, where an instruction's address in the file is not + # its offset there + probeOf = ps: ps.runCommandCC "vestigia-probe" { + hardeningDisable = [ "fortify" ]; + } '' + mkdir -p $out/bin + $CC -O0 -g -fno-pie -no-pie -I${vestigia.include}/include \ + -I${vestigia.valgrindDev}/include \ + -o $out/bin/probe ${../etc/probe/harness.c} \ + ${vestigia.include}/include/vestigia.c + $READELF -h $out/bin/probe | grep -q 'Type: *EXEC' + ''; + + probe = probeOf pkgs; + cross = probeOf pkgs.pkgsCross.aarch64-multiplatform; + x86_64 = pkgs.stdenv.hostPlatform.isx86_64; + + threads = pkgs.runCommandCC "vestigia-probe-threads" { } '' + mkdir -p $out/bin + cc -O0 -pthread -o $out/bin/threads ${../etc/probe/threads.c} + ''; + + secrets = [ "00000005" "0000003a" "000000c3" ]; + objects = [ "/bin/probe" ]; + + trace = vestigia.traceOf { + name = "probe"; + harness = "${probe}/bin/probe"; + runs = map (s: [ s ]) secrets; + inherit objects; + }; + + diffOf = name: harness: vestigia.diffOf { + inherit name objects; + harness = "${harness}/bin/probe"; + runs = map (s: [ s ]) secrets; + }; + diff = diffOf "probe" probe; + diffCross = diffOf "probe-aarch64" cross; + + # each report's (class, function) pairs in the harness, sorted + expected = { + trace = [ [ "address" "lookup" ] [ "address" "rare" ] + [ "control" "branchy" ] [ "control" "calls" ] + [ "control" "rare" ] [ "operand" "divide" ] + [ "operand" "divide_memory" ] ]; + diff = [ [ "address" "lookup" ] [ "control" "branchy" ] + [ "control" "calls" ] [ "control" "rare" ] + [ "operand" "divide" ] [ "operand" "divide_memory" ] ]; + }; +in +pkgs.runCommand "vestigia-probe-check" { + nativeBuildInputs = [ pkgs.jq ]; +} '' + check() { + got=$(jq -c "$2" "$1") + if [ "$got" != "$3" ]; then + echo "probe: $1: expected $3, got $got" >&2 + exit 1 + fi + } + check ${trace}/trace.json \ + '[.sites[] | [.class, .function]] | unique' \ + '${builtins.toJSON expected.trace}' + check ${diff}/diff.json \ + '[.origins[] | [.class, .frames[0].function]] | unique' \ + '${builtins.toJSON expected.diff}' + + # every secret is traced under taint, and the table index on the + # third secret's path is that run's alone + check ${trace}/trace.json '[.runs[] | .exit]' '[0,0,0]' + check ${trace}/trace.json \ + '[.sites[] | select(.function == "rare" and .class == "address") + | .runs]' \ + '[["run-3"]]' + + # both instruments name each instruction they both saw by one + # address: a division memcheck reports where it divides, not at the + # memory access before it + check ${trace}/trace.json \ + '[.sites[] | select(.class == "operand") | .offset] | sort' \ + "$(jq -c '[.origins[] | select(.class == "operand") | .offset] + | sort' ${diff}/diff.json)" + ${lib.optionalString x86_64 '' + check ${diffCross}/diff.json \ + '[.origins[] | [.class, .frames[0].function]] | unique' \ + '${builtins.toJSON expected.diff}' + ''} + + # each origin's stack runs from its own function to the call in + # main that reached it + for d in ${diff} ${lib.optionalString x86_64 "${diffCross}"}; do + check $d/diff.json \ + '[.origins[] | [.stacks[0][0].function, .stacks[0][1].function] + | .[1]] | unique' \ + '["main"]' + done + + # each run as it was made: the declared, then the control + check ${diff}/diff.json \ + '[.runs[] | [.kind, .arguments[1], .status]]' \ + '${builtins.toJSON (map (s: [ "declared" s 0 ]) secrets + ++ [ [ "control" (builtins.head secrets) 0 ] ])}' + + v=${vestigia.vestigia}/bin/vestigia + + # a control must repeat the run it controls, and every run must lay + # out as the first: its arguments of one number and length + $v record --dump a -- ${probe}/bin/probe 00000005 + $v record --dump b -- ${probe}/bin/probe 0000003a + $v record --dump c -- ${probe}/bin/probe 000000003a + if $v diff --control b a a 2> refusal; then + echo "probe: a control repeating another secret was taken" >&2 + exit 1 + fi + grep -q 'does not repeat' refusal + if $v diff a c 2> refusal; then + echo "probe: a run with a longer argument was compared" >&2 + exit 1 + fi + grep -q 'number or length' refusal + + # a guest that starts a second thread is stopped, and its dump + # refused + if $v record --dump threads -- ${threads}/bin/threads; then + echo "probe: a guest's second thread was recorded" >&2 + exit 1 + fi + if $v diff threads threads 2> refusal; then + echo "probe: a stopped guest's dump was compared" >&2 + exit 1 + fi + grep -q 'second thread' refusal + touch $out +'' diff --git a/plugin/vestigia-qemu.c b/plugin/vestigia-qemu.c @@ -18,53 +18,196 @@ * Nothing needs aligning, and no taint is propagated, so effects of * control flow are seen as well as those of data flow. * + * It reads each instruction's bytes, x86_64's or aarch64's, for three + * kinds. A return goes where the call before it sent it, and is + * marked. An integer division takes a time that depends on its + * operands, so it gets an order-sensitive fingerprint of their + * values: the dividend and a register divisor read before it runs, + * and a divisor in memory as it is loaded. A division run as often + * but on other operands computed them from the secret. And a call, + * with the returns, keeps a shadow stack of the calls in progress, + * so that an instruction that could be an origin (one ending a + * translation block, as every branch does, one touching memory, or a + * division) is given the contexts it ran in: the chains of calls, by + * their addresses, that reached it, up to CONTEXTS of them. + * * At exit it writes, to the file named by its `out` argument: * - * I <pc> <count> <accesses> <fingerprint> <order> <object> <offset> + * I <pc> <count> <accesses> <fingerprint> <order> <object> <address> * E <from> <to> <count> + * R <pc> + * D <pc> <operands> + * C <context> <caller> <call> + * K <pc> <partial> <context>... * * in hex, where <object> is the file the instruction's bytes were - * mapped from ("-" if none) and <offset> its offset in that file. + * mapped from ("-" if none, or if the file's program headers cannot + * be read) and <address> its address in that file: where the file's + * program headers place it, as its disassembly and line tables name + * it. In a shared library that is often the instruction's offset in + * the file; in an executable linked at a fixed address it is not. A + * context is the call at <call> made in context <caller>, context 0 + * being the program's own; <partial> is 1 where an instruction ran in + * more contexts than are kept. A guest that started a second thread + * gets the line `T 2` alone. * - * A single-threaded guest is assumed: the state below is unlocked. + * The state below is unlocked, so one guest thread is recorded: a + * guest that starts a second one is stopped there, its dump saying + * so, rather than recorded by threads racing on it. */ +#include <elf.h> #include <glib.h> #include <inttypes.h> #include <stdio.h> #include <stdlib.h> #include <string.h> +#include <unistd.h> #include <qemu-plugin.h> QEMU_PLUGIN_EXPORT int qemu_plugin_version = QEMU_PLUGIN_VERSION; +/* what an instruction is to a comparison, read from its bytes */ +enum { PLAIN, RETURN, DIVISION, CALL }; + +/* the most contexts kept for one instruction */ +#define CONTEXTS 16 + +/* registers by number: x86_64's in ModRM order, aarch64's X0 to X30; + aarch64's 31 is the zero register where a division names it */ +enum { RAX = 0, RDX = 2, ZR = 31, GPRS = 32 }; + +typedef struct Decoded { + int kind; + /* a division's: its width in bits; on aarch64 its two registers, + on x86_64 its divisor's (-1 in memory), that register's second + byte where `high`, the dividend being RDX:RAX or AX */ + int width, a, b, high; +} Decoded; + typedef struct Insn { uint64_t pc; uint64_t size; uintptr_t haddr; + Decoded d; uint64_t count; uint64_t accesses; uint64_t fingerprint; uint64_t order; /* the successors, in the order they followed */ + uint64_t operands; /* a division's operands, in order */ GHashTable *succ; /* target pc -> count, for transfers */ + uint32_t last; /* the context it last ran in */ + uint32_t contexts[CONTEXTS]; + int ncontexts, partial; + int origin; /* it ends a block or divides: it could be one */ + /* a call's: the context it was last made in, and the one it made */ + uint32_t made_in, made; } Insn; +/* a context: the context a call was made in, and the call */ +typedef struct Context { + uint32_t caller; + uint64_t call; +} Context; + +/* a call in progress: where it returns, and the context it left */ +typedef struct Call { + uint64_t ret; + uint32_t left; +} Call; + static GHashTable *insns; /* pc -> Insn */ static Insn *prev; /* the instruction executed last */ static char *out_path; +static gint vcpus; /* the guest's threads, a vCPU each */ +static gboolean aarch64; /* the guest's ISA: aarch64, else x86_64 */ +static GArray *contexts; /* Context by number, 0 the program's own */ +static GHashTable *interned; /* Context -> its number */ +static GArray *calls; /* the calls in progress, innermost last */ +static uint32_t current; /* the context the guest runs in */ +static struct qemu_plugin_register *gpr[GPRS]; +static GByteArray *regbuf; 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) { +static Decoded decode_aarch64(const uint8_t *b, size_t n) { + Decoded d = { PLAIN, 0, 0, 0, 0 }; + uint32_t w; + if (n < 4) return d; + w = (uint32_t)b[0] | (uint32_t)b[1] << 8 | (uint32_t)b[2] << 16 + | (uint32_t)b[3] << 24; + /* RET Xn, RETAA, RETAB */ + if ((w & 0xfffffc1f) == 0xd65f0000 || w == 0xd65f0bff + || w == 0xd65f0fff) + d.kind = RETURN; + /* BL, BLR, BLRAAZ, BLRABZ, BLRAA, BLRAB */ + else if ((w & 0xfc000000) == 0x94000000 + || (w & 0xfffffc1f) == 0xd63f0000 + || (w & 0xfffff81f) == 0xd63f081f + || (w & 0xfffff800) == 0xd73f0800) + d.kind = CALL; + /* UDIV, SDIV */ + else if ((w & 0x7fe0f800) == 0x1ac00800) { + d.kind = DIVISION; + d.width = (w >> 31) ? 64 : 32; + d.a = (int)((w >> 5) & 31); + d.b = (int)((w >> 16) & 31); + } + return d; +} + +static Decoded decode_x86_64(const uint8_t *b, size_t n) { + Decoded d = { PLAIN, 0, 0, 0, 0 }; + size_t k = 0; + int opsize = 0, rex = 0; + /* past the legacy prefixes, then a REX */ + while (k < n && (b[k] == 0x66 || b[k] == 0x67 || b[k] == 0xf0 + || b[k] == 0xf2 || b[k] == 0xf3 || b[k] == 0x2e + || b[k] == 0x36 || b[k] == 0x3e || b[k] == 0x26 + || b[k] == 0x64 || b[k] == 0x65)) { + if (b[k] == 0x66) opsize = 1; + k++; + } + if (k < n && (b[k] & 0xf0) == 0x40) rex = b[k++]; + if (k >= n) return d; + /* RET, RET imm16 */ + if (b[k] == 0xc3 || b[k] == 0xc2) d.kind = RETURN; + /* CALL rel32, CALL r/m: FF with ModRM's reg 2 */ + else if (b[k] == 0xe8 + || (b[k] == 0xff && k + 1 < n && ((b[k + 1] >> 3) & 7) == 2)) + d.kind = CALL; + /* DIV, IDIV: F6 or F7 with ModRM's reg 6 or 7 */ + else if ((b[k] == 0xf6 || b[k] == 0xf7) && k + 1 < n + && ((b[k + 1] >> 3) & 6) == 6) { + int modrm = b[k + 1], rm = modrm & 7; + d.kind = DIVISION; + d.width = b[k] == 0xf6 ? 8 : (rex & 8) ? 64 : opsize ? 16 : 32; + if ((modrm >> 6) != 3) d.a = -1; + /* without REX, a byte register 4 to 7 is AH, CH, DH, or BH */ + else if (d.width == 8 && rex == 0 && rm >= 4) { + d.a = rm - 4; + d.high = 1; + } else + d.a = rm | ((rex & 1) << 3); + } + return d; +} + +static Insn *insn_at(uint64_t pc, uint64_t size, uintptr_t haddr, + Decoded d) { 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->d = d; i->fingerprint = fnv_offset; i->order = fnv_offset; + i->operands = fnv_offset; + i->last = UINT32_MAX; + i->made_in = UINT32_MAX; 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); @@ -83,24 +226,178 @@ static void transfer(Insn *from, uint64_t to) { (*n)++; } +static guint context_hash(gconstpointer p) { + const Context *c = p; + return (guint)((c->call * 0x9e3779b97f4a7c15ULL) >> 32) ^ c->caller; +} + +static gboolean context_equal(gconstpointer a, gconstpointer b) { + const Context *x = a, *y = b; + return x->caller == y->caller && x->call == y->call; +} + +static uint32_t context_of(uint32_t caller, uint64_t call) { + Context c = { caller, call }; + gpointer v; + if (g_hash_table_lookup_extended(interned, &c, NULL, &v)) + return GPOINTER_TO_UINT(v); + uint32_t n = contexts->len; + g_array_append_val(contexts, c); + g_hash_table_insert(interned, g_memdup2(&c, sizeof c), + GUINT_TO_POINTER(n)); + return n; +} + +/* a call made: it returns past itself */ +static void enter(Insn *call) { + Call c = { call->pc + call->size, current }; + g_array_append_val(calls, c); + if (call->made_in != current) { + call->made_in = current; + call->made = context_of(current, call->pc); + } + current = call->made; +} + +/* a return made, to pc: the innermost call returning there ends, and + any inside it that never returned; a return no call made leaves + the stack as it is */ +static void leave(uint64_t pc) { + for (guint k = calls->len; k > 0; k--) { + const Call *c = &g_array_index(calls, Call, k - 1); + if (c->ret == pc) { + current = c->left; + g_array_set_size(calls, k - 1); + return; + } + } +} + +static void note_context(Insn *i) { + i->last = current; + /* once more have been seen than are kept, no more is learnt */ + if (i->partial) return; + for (int k = 0; k < i->ncontexts; k++) + if (i->contexts[k] == current) return; + if (i->ncontexts < CONTEXTS) + i->contexts[i->ncontexts++] = current; + else + i->partial = 1; +} + static void on_exec(unsigned int vcpu, void *udata) { Insn *i = udata; (void)vcpu; if (prev != NULL) { prev->order = (prev->order ^ i->pc) * fnv_prime; if (i->pc != prev->pc + prev->size) transfer(prev, i->pc); + if (prev->d.kind == CALL) enter(prev); + else if (prev->d.kind == RETURN) leave(i->pc); } prev = i; i->count++; + if (i->origin && i->last != current) note_context(i); +} + +static void mix(Insn *i, uint64_t v) { + i->operands = (i->operands ^ v) * fnv_prime; +} + +static uint64_t read_gpr(int r) { + uint64_t v = 0; + int n; + if (aarch64 && r == ZR) return 0; + g_byte_array_set_size(regbuf, 0); + n = qemu_plugin_read_register(gpr[r], regbuf); + /* in the guest's byte order, little-endian as the host's */ + if (n > 0) memcpy(&v, regbuf->data, MIN((size_t)n, sizeof v)); + return v; +} + +static void on_divide(unsigned int vcpu, void *udata) { + Insn *i = udata; + uint64_t mask = i->d.width == 64 ? ~0ULL : (1ULL << i->d.width) - 1; + (void)vcpu; + if (aarch64) { + mix(i, read_gpr(i->d.a) & mask); + mix(i, read_gpr(i->d.b) & mask); + return; + } + if (i->d.width == 8) + mix(i, read_gpr(RAX) & 0xffff); + else { + mix(i, read_gpr(RAX) & mask); + mix(i, read_gpr(RDX) & mask); + } + /* a divisor in memory is mixed in as it is loaded */ + if (i->d.a >= 0) + mix(i, (read_gpr(i->d.a) >> (i->d.high ? 8 : 0)) & mask); } 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++; + if (i->last != current) note_context(i); + if (i->d.kind == DIVISION) { + qemu_plugin_mem_value v = qemu_plugin_mem_get_value(info); + switch (v.type) { + case QEMU_PLUGIN_MEM_VALUE_U8: mix(i, v.data.u8); break; + case QEMU_PLUGIN_MEM_VALUE_U16: mix(i, v.data.u16); break; + case QEMU_PLUGIN_MEM_VALUE_U32: mix(i, v.data.u32); break; + case QEMU_PLUGIN_MEM_VALUE_U64: mix(i, v.data.u64); break; + case QEMU_PLUGIN_MEM_VALUE_U128: + mix(i, v.data.u128.low); + mix(i, v.data.u128.high); + break; + } + } +} + +/* the general registers a division reads, by the names QEMU's + debugger stub gives them */ +static void find_registers(void) { + static const char *x86_64[16] = { + "rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi", + "r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15" }; + GArray *rs = qemu_plugin_get_registers(); + int wanted = aarch64 ? 31 : 16; + for (guint k = 0; k < rs->len; k++) { + qemu_plugin_reg_descriptor *r = + &g_array_index(rs, qemu_plugin_reg_descriptor, k); + for (int j = 0; j < wanted; j++) { + char name[8]; + if (aarch64) g_snprintf(name, sizeof name, "x%d", j); + if (strcmp(r->name, aarch64 ? name : x86_64[j]) == 0) + gpr[j] = r->handle; + } + } + g_array_free(rs, TRUE); + for (int j = 0; j < wanted; j++) + if (gpr[j] == NULL) { + fprintf(stderr, "vestigia-qemu: QEMU names no register %d, by " + "which a division's operands are read\n", j); + _exit(EXIT_FAILURE); + } +} + +static void on_vcpu_init(qemu_plugin_id_t id, unsigned int vcpu) { + (void)id; + (void)vcpu; + if (g_atomic_int_add(&vcpus, 1) == 0) + find_registers(); + else { + FILE *out = fopen(out_path, "w"); + if (out != NULL) { + fprintf(out, "T 2\n"); + fclose(out); + } + fprintf(stderr, "vestigia-qemu: the guest started a second thread, " + "which a trace cannot record; the run is stopped\n"); + _exit(EXIT_FAILURE); + } } static void on_translate(qemu_plugin_id_t id, struct qemu_plugin_tb *tb) { @@ -108,11 +405,19 @@ static void on_translate(qemu_plugin_id_t id, struct qemu_plugin_tb *tb) { (void)id; for (size_t k = 0; k < n; k++) { struct qemu_plugin_insn *qi = qemu_plugin_tb_get_insn(tb, k); + uint8_t bytes[16]; + size_t len = qemu_plugin_insn_data(qi, bytes, sizeof bytes); Insn *i = insn_at(qemu_plugin_insn_vaddr(qi), qemu_plugin_insn_size(qi), - (uintptr_t)qemu_plugin_insn_haddr(qi)); + (uintptr_t)qemu_plugin_insn_haddr(qi), + aarch64 ? decode_aarch64(bytes, len) + : decode_x86_64(bytes, len)); + if (k == n - 1 || i->d.kind == DIVISION) i->origin = 1; qemu_plugin_register_vcpu_insn_exec_cb(qi, on_exec, QEMU_PLUGIN_CB_NO_REGS, i); + if (i->d.kind == DIVISION) + qemu_plugin_register_vcpu_insn_exec_cb(qi, on_divide, + QEMU_PLUGIN_CB_R_REGS, i); qemu_plugin_register_vcpu_mem_cb(qi, on_mem, QEMU_PLUGIN_CB_NO_REGS, QEMU_PLUGIN_MEM_RW, i); } @@ -158,6 +463,70 @@ static const Region *region_of(GArray *rs, uintptr_t a) { return NULL; } +/* a file's loadable segments, by which a file offset is placed at + the address the file's own disassembly gives it */ +typedef struct Segments { + guint n; + Elf64_Phdr *load; +} Segments; + +static Segments *read_segments(const char *path) { + FILE *f = fopen(path, "rb"); + Elf64_Ehdr eh; + Segments *s = NULL; + if (f == NULL) return NULL; + /* a little-endian ELF64 object, as x86_64 and aarch64 build */ + if (fread(&eh, sizeof eh, 1, f) != 1 + || memcmp(eh.e_ident, ELFMAG, SELFMAG) != 0 + || eh.e_ident[EI_CLASS] != ELFCLASS64 + || eh.e_ident[EI_DATA] != ELFDATA2LSB + || eh.e_phentsize != sizeof(Elf64_Phdr) + || fseek(f, (long)eh.e_phoff, SEEK_SET) != 0) { + fclose(f); + return NULL; + } + s = g_new0(Segments, 1); + s->load = g_new0(Elf64_Phdr, eh.e_phnum); + for (guint k = 0; k < eh.e_phnum; k++) { + Elf64_Phdr ph; + if (fread(&ph, sizeof ph, 1, f) != 1) { + g_free(s->load); + g_free(s); + fclose(f); + return NULL; + } + if (ph.p_type == PT_LOAD) s->load[s->n++] = ph; + } + fclose(f); + return s; +} + +/* the segments of the file at a path, read once; NULL if they cannot + be read, which is said once */ +static Segments *segments_of(GHashTable *cache, const char *path) { + gpointer v; + if (g_hash_table_lookup_extended(cache, path, NULL, &v)) return v; + Segments *s = read_segments(path); + if (s == NULL) + fprintf(stderr, "vestigia-qemu: cannot read the program headers " + "of %s; its instructions are named by no object\n", path); + g_hash_table_insert(cache, g_strdup(path), s); + return s; +} + +/* where a file's segments place the byte at a file offset */ +static gboolean address_in(const Segments *s, uint64_t off, + uint64_t *addr) { + for (guint k = 0; k < s->n; k++) { + const Elf64_Phdr *p = &s->load[k]; + if (off >= p->p_offset && off - p->p_offset < p->p_filesz) { + *addr = p->p_vaddr + (off - p->p_offset); + return TRUE; + } + } + return FALSE; +} + static void at_exit(qemu_plugin_id_t id, void *p) { (void)id; (void)p; @@ -167,21 +536,34 @@ static void at_exit(qemu_plugin_id_t id, void *p) { return; } GArray *rs = read_maps(); + GHashTable *segs = g_hash_table_new(g_str_hash, g_str_equal); 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) + Segments *s = r != NULL ? segments_of(segs, r->path) : NULL; + uint64_t addr; + if (s != NULL + && address_in(s, (uint64_t)(i->haddr - r->start) + r->offset, + &addr)) fprintf(out, "I %" PRIx64 " %" PRIx64 " %" PRIx64 " %016" PRIx64 " %016" PRIx64 " %s %" PRIx64 "\n", i->pc, i->count, - i->accesses, i->fingerprint, i->order, r->path, - (uint64_t)(i->haddr - r->start) + r->offset); + i->accesses, i->fingerprint, i->order, r->path, addr); else fprintf(out, "I %" PRIx64 " %" PRIx64 " %" PRIx64 " %016" PRIx64 " %016" PRIx64 " - 0\n", i->pc, i->count, i->accesses, i->fingerprint, i->order); + if (i->d.kind == RETURN) fprintf(out, "R %" PRIx64 "\n", i->pc); + if (i->d.kind == DIVISION) + fprintf(out, "D %" PRIx64 " %016" PRIx64 "\n", i->pc, i->operands); + if (i->ncontexts > 0) { + fprintf(out, "K %" PRIx64 " %d", i->pc, i->partial); + for (int k = 0; k < i->ncontexts; k++) + fprintf(out, " %" PRIx32, i->contexts[k]); + fprintf(out, "\n"); + } GHashTableIter jt; gpointer tk, tn; g_hash_table_iter_init(&jt, i->succ); @@ -189,13 +571,18 @@ static void at_exit(qemu_plugin_id_t id, void *p) { fprintf(out, "E %" PRIx64 " %" PRIx64 " %" PRIx64 "\n", i->pc, *(uint64_t *)tk, *(uint64_t *)tn); } + for (guint k = 1; k < contexts->len; k++) { + const Context *c = &g_array_index(contexts, Context, k); + fprintf(out, "C %x %" PRIx32 " %" PRIx64 "\n", k, c->caller, + c->call); + } 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; + aarch64 = strcmp(info->target_name, "aarch64") == 0; for (int k = 0; k < argc; k++) if (strncmp(argv[k], "out=", 4) == 0) out_path = g_strdup(argv[k] + 4); @@ -204,6 +591,16 @@ QEMU_PLUGIN_EXPORT int qemu_plugin_install(qemu_plugin_id_t id, return -1; } insns = g_hash_table_new(g_int64_hash, g_int64_equal); + regbuf = g_byte_array_new(); + contexts = g_array_new(FALSE, FALSE, sizeof(Context)); + interned = g_hash_table_new_full(context_hash, context_equal, g_free, + NULL); + calls = g_array_new(FALSE, FALSE, sizeof(Call)); + { + Context own = { 0, 0 }; + g_array_append_val(contexts, own); + } + qemu_plugin_register_vcpu_init_cb(id, on_vcpu_init); 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 @@ -79,21 +79,19 @@ memcheckTests = testGroup "memcheck" traceTests :: TestTree traceTests = testGroup "trace" [ testCase "the probe's secret addresses are its sites" $ do - tr <- trace <$> fixture "probe.xml" - fmap (\s -> (siClass s, frFn (siFrame s), frLine (siFrame s))) - (trSites tr) + tr <- one <$> fixture "probe.xml" + fmap (\s -> (siClass s, frFn (at s), frLine (at s))) (trSites tr) @?= [ (Address, Just "lookup", Just 16) , (Address, Just "main", Just 33) ] trUnrelated tr @?= [] trFaults tr @?= [] , testCase "only a secret's descendants are sites" $ do - tr <- trace <$> either assertFailure pure (readMemcheck mixed) - fmap (\s -> (siClass s, frIp (siFrame s), siCount s)) - (trSites tr) + tr <- one <$> either assertFailure pure (readMemcheck mixed) + fmap (\s -> (siClass s, frIp (at s), siCount s)) (trSites tr) @?= [ (Control, 0x10, 5) ] - fmap meKind (trUnrelated tr) @?= [ "UninitValue" ] - fmap meKind (trFaults tr) @?= [ "InvalidRead" ] + fmap (meKind . snd) (trUnrelated tr) @?= [ "UninitValue" ] + fmap (meKind . snd) (trFaults tr) @?= [ "InvalidRead" ] , testCase "a frame is named by its address in its object" $ do os <- either assertFailure pure @@ -109,16 +107,39 @@ traceTests = testGroup "trace" objectOffset os (f "/lib/libc.so.6" 0x7000) @?= Nothing , testCase "a site is kept to the objects named" $ do - tr <- trace <$> fixture "probe.xml" + tr <- one <$> fixture "probe.xml" length (trSites (sitesIn [ "probe" ] tr)) @?= 2 trSites (sitesIn [ "libc" ] tr) @?= [] length (trSites (sitesIn [] tr)) @?= 2 , testCase "one site per instruction, whatever called it" $ do - tr <- trace <$> either assertFailure pure (readMemcheck twoCallers) - fmap (\s -> (frIp (siFrame s), siCount s, length (siStacks s))) + tr <- one <$> either assertFailure pure (readMemcheck twoCallers) + fmap (\s -> (frIp (at s), siCount s, length (siStacks s))) (trSites tr) @?= [ (0x10, 7, 2) ] + + , testCase "runs' sites are one per instruction, with their runs" $ do + a <- either assertFailure pure (readMemcheck mixed) + b <- either assertFailure pure (readMemcheck twoCallers) + -- the same object loaded 0x100 higher in the second run: its + -- branch at 0x110 is the first run's at 0x10 + let obj = "/lib.so" + inObj mc = mc { mcErrors = + [ e { meStack = [ f { frObj = Just obj } + | f <- meStack e ] } + | e <- mcErrors mc ] } + moved mc = mc { mcErrors = + [ e { meStack = [ f { frIp = frIp f + 0x100 } + | f <- meStack e ] } + | e <- mcErrors mc ] } + tr = trace + [ Taint "a" (inObj a) [ (0, obj) ] (Just 0) + , Taint "b" (moved (inObj b)) [ (0x100, obj) ] (Just 1) ] + fmap (\s -> (siClass s, lcOffset (siAt s), siCount s, siRuns s + , length (siStacks s))) + (trSites tr) + @?= [ (Control, Just 0x10, 5 + 7, [ 0, 1 ], 1 + 2) ] + fmap fst (trUnrelated tr) @?= [ 0 ] ] diffTests :: TestTree @@ -245,6 +266,63 @@ diffTests = testGroup "diff" -- in the same order, nothing dvOrigins (diffDumps [ a, a ]) @?= [] + , testCase "a return goes where its call sent it: downstream" $ do + -- 0x50 returns four times in each run, to 0x30 twice in A and + -- three times in B, as the branch at 0x10 sent it + a <- either assertFailure pure (readDump (dumpA <> T.unlines + [ "I 50 4 0 aaaa 0 /lib.so 50", "R 50", "E 50 30 2" + , "E 50 60 2" ])) + b <- either assertFailure pure (readDump (dumpB <> T.unlines + [ "I 50 4 0 aaaa 0 /lib.so 50", "R 50", "E 50 30 3" + , "E 50 60 1" ])) + let dv = diffDumps [ a, b ] + fmap (\(o, i) -> (o, inPc i)) (dvOrigins dv) + @?= [ (ControlOrigin, 0x10), (AddressOrigin, 0x20) ] + fmap inPc (dvDownstream dv) @?= [ 0x30, 0x50 ] + + , testCase "a division on other operands is an operand origin" $ do + -- 0x40 divides once in every run, on other operands in B; in a + -- designed run, on others again + a <- either assertFailure pure (readDump (dumpA <> "D 40 1111\n")) + b <- either assertFailure pure (readDump (dumpB <> "D 40 2222\n")) + x <- either assertFailure pure (readDump (dumpA <> "D 40 3333\n")) + let dv = diffDumps [ a, b ] + fmap (\(o, i) -> (o, inPc i)) (dvOrigins dv) + @?= [ (ControlOrigin, 0x10), (AddressOrigin, 0x20) + , (OperandOrigin, 0x40) ] + M.lookup (OperandOrigin, 0x40) (dvBehaviour (diffDumps [ a, b, a ])) + @?= Just ([ [ 0, 2 ], [ 1 ] ], []) + notElem 0x40 (fmap inPc (dvDownstream dv)) @? "downstream" + -- a designed run's operands say nothing of the secret + fmap (\(o, i) -> (o, inPc i)) (dvOrigins (diffDesigned [] [ a, a ] + [ x ])) @?= [] + -- a control dividing other operands sets it apart as unsteady + fmap inPc (dvUnsteady (diffControlled [ x ] [ a, b ])) @?= [ 0x40 ] + + , testCase "an origin is given the calls that reached it" $ do + -- 0x10 ran in context 2 (the call at 0x40 made in context 1, + -- the call at 0x30 made in the program's own) in A, and in the + -- program's own context too in B + let calls = T.unlines + [ "C 1 0 30", "C 2 1 40" ] + a <- either assertFailure pure (readDump (dumpA <> calls + <> "K 10 0 2\n")) + b <- either assertFailure pure (readDump (dumpB <> calls + <> "K 10 1 2 0\n")) + callsOf a 2 @?= [ (Just "/lib.so", 0x40), (Just "/lib.so", 0x30) ] + callsOf a 0 @?= [] + M.lookup 0x10 (dvCallers (diffDumps [ a, b ])) + @?= Just ([ [ (Just "/lib.so", 0x40), (Just "/lib.so", 0x30) ] + , [] ], True) + M.lookup 0x10 (dvCallers (diffDumps [ a, a ])) + @?= Nothing + + , testCase "contexts that do not chain back are refused" $ do + isLeft (readDump (dumpA <> "C 1 2 30\nC 2 1 40\n")) + @? "a context made in a later one" + isLeft (readDump (dumpA <> "K 10 0 5\n")) + @? "a context never made" + , testCase "identical runs have no origins" $ do a <- either assertFailure pure (readDump dumpA) let dv = diffDumps [ a, a ] @@ -270,6 +348,34 @@ diffTests = testGroup "diff" , testCase "an unreadable line is refused" $ isLeft (readDump "I 10 zz 0 f 0 /lib.so 0\n") @? "accepted" + + , testCase "a guest stopped for a second thread is refused" $ + isLeft (readDump "T 2\n") @? "accepted" + + , testCase "a run's record reads whole" $ do + d <- either assertFailure pure (readDump (dumpA <> T.unlines + [ "A 2f62696e2f70", "A", "A 3035", "N 2 1a", "X -9" ])) + dpRun d @?= Just (Run [ "/bin/p", "", "05" ] (2, 0x1a) (-9)) + fmap dpRun (readDump dumpA) @?= Right Nothing + + , testCase "an incomplete record is refused" $ do + isLeft (readDump (dumpA <> "A 3035\nN 2 1a\n")) @? "no status" + isLeft (readDump (dumpA <> "N 2 1a\nX 0\n")) @? "no arguments" + isLeft (readDump (dumpA <> "A 305\nN 2 1a\nX 0\n")) + @? "half a byte" + + , testCase "runs made apart are told apart" $ do + let r = Run [ "/bin/p", "05" ] (2, 0x1a) 0 + runApart r r { rnArguments = [ "/bin/p", "3a" ], rnStatus = 1 } + @?= Nothing + isJust (runApart r r { rnArguments = [ "/bin/q", "05" ] }) + @? "another program" + isJust (runApart r r { rnArguments = [ "/bin/p", "005" ] }) + @? "a longer argument" + isJust (runApart r r { rnArguments = [ "/bin/p", "05", "x" ] }) + @? "another argument" + isJust (runApart r r { rnEnvironment = (2, 0x1b) }) + @? "a larger environment" ] where -- 0x10 is a branch run four times, taken twice in A and three @@ -304,6 +410,19 @@ symbolTests = testGroup "symbol" @?= [ (0x1234, [ Place (Just "inner") (Just "/src/a.rs") (Just 12) , Place (Just "outer") (Just "/src/b.rs") (Just 40) ]) , (0x5678, [ Place Nothing Nothing Nothing ]) ] + + , testCase "a legacy Rust name's escapes and hash undone" $ do + rustLegacy "decaf377::ark_curve::encoding::_$LT$impl$u20$decaf377\ + \..ark_curve..element..projective..Element$GT$::vartime\ + \_compress_to_field::h346fee1681a4f34f" + @?= "decaf377::ark_curve::encoding::<impl decaf377::ark_curve::\ + \element::projective::Element>::vartime_compress_to_field" + rustLegacy "_$LT$$RF$T$u20$as$u20$core..fmt..Debug$GT$::fmt::\ + \h0123456789abcdef" + @?= "<&T as core::fmt::Debug>::fmt" + -- a C++ name, or one without a hash, is left as it is + rustLegacy "std::vector<int>::size" @?= "std::vector<int>::size" + rustLegacy "a::b$LT$c" @?= "a::b$LT$c" ] jsonTests :: TestTree @@ -314,6 +433,14 @@ jsonTests = testGroup "json" @?= "{\n \"b\": 1,\n \"a\": \"q\\\"\\n\\u0001\"\n}\n" ] +-- a trace of one run, of which memcheck's report is all that is +-- known +one :: Memcheck -> Trace +one mc = trace [ Taint "run" mc [] Nothing ] + +at :: Site -> Frame +at = lcFrame . siAt + fixture :: FilePath -> IO Memcheck fixture f = do b <- BS.readFile ("test/fixtures/" <> f) @@ -324,6 +451,11 @@ isLeft e = case e of Left _ -> True Right _ -> False +isJust :: Maybe a -> Bool +isJust m = case m of + Just _ -> True + Nothing -> False + -- a secret branch, an uninitialised heap read, a bad read, and a -- leak report mixed :: T.Text diff --git a/vestigia.cabal b/vestigia.cabal @@ -38,6 +38,7 @@ library , Vestigia.Xml build-depends: base >= 4.9 && < 5 + , bytestring , containers , process , text @@ -55,6 +56,7 @@ executable vestigia build-depends: base , bytestring + , containers , directory , filepath , process