vestigia

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

commit 89244a3ad19b085993be5a2f0ca660964b8202b7
parent b9cdbe93429f6df5a4e1c243c147bfd3910781b8
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat, 17 Oct 2026 23:43:47 +0400

marks: a compilation unit that records where each object sits

A site is reported at a run-time address, and only the loader knows
where it put the object holding it. The marks move from the header
into include/vestigia.c (vestigia.h declares them), which sets
_GNU_SOURCE for dl_iterate_phdr and, on the first secret mark,
writes each loaded object's load bias and path to the file
VESTIGIA_OBJECTS names. `vestigia run` sets it, lists the objects in
its report, and gives every frame its address in its object. The
same file serves a foreign function interface, so vestigia-ffi.c
goes.

Diffstat:
MCHANGELOG | 5+++--
MREADME.md | 9++++++---
Mapp/Main.hs | 32+++++++++++++++++++++++++-------
Metc/spike/penumbra/default.nix | 1+
Metc/spike/poly1305/Harness.hs | 4++--
Metc/spike/poly1305/default.nix | 2+-
Metc/spike/secp256k1/Harness.hs | 4++--
Metc/spike/secp256k1/default.nix | 2+-
Dinclude/vestigia-ffi.c | 13-------------
Ainclude/vestigia.c | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minclude/vestigia.h | 14+++++---------
Mlib/Vestigia/Trace.hs | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mnix/lib.nix | 2+-
Mtest/Main.hs | 13+++++++++++++
14 files changed, 191 insertions(+), 56 deletions(-)

diff --git a/CHANGELOG b/CHANGELOG @@ -2,8 +2,9 @@ - 0.1.0 (unreleased) * Initial version, with two instruments over a harness that marks - its secret (`vestigia.h`, or `vestigia-ffi.c` through a foreign - function interface): + 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 diff --git a/README.md b/README.md @@ -41,10 +41,13 @@ penumbra_audit_public_key(ws); vestigia_public(ws + 32, 32); /* the public key it derives */ ``` +The harness compiles `include/vestigia.c` beside it, which defines +the marks; a harness that reaches them through a foreign function +interface (a Haskell `foreign import ccall`) compiles the same file. Outside valgrind the marks are no-ops, so the same harness can be -timed by censor unchanged. A harness that reaches the marks through -a foreign function interface (a Haskell `foreign import ccall`) -compiles `include/vestigia-ffi.c`, which defines them as functions. +timed by censor unchanged. On Linux the first secret mark also +writes where the loader put each object, for `vestigia run` to name +every site by its object and its address in it. ### Taint: `vestigia run` diff --git a/app/Main.hs b/app/Main.hs @@ -22,10 +22,11 @@ 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.Environment (getArgs, lookupEnv) +import System.Environment (getArgs, getEnvironment, lookupEnv) import System.Exit (ExitCode(..), exitWith) import System.IO (hClose, hPutStrLn, openTempFile, stderr) import System.Process (proc, waitForProcess, withCreateProcess) +import qualified System.Process as P import Paths_vestigia (version) import Vestigia.Diff @@ -35,7 +36,8 @@ import Vestigia.Json (encode) import Vestigia.Layout (fixLayout) import Vestigia.Memcheck (readMemcheck) import Vestigia.Symbol (symbolize) -import Vestigia.Trace (trace, traceJson, traceSummary) +import Vestigia.Trace + (readObjects, trace, traceJson, traceSummary, withObjects) data Opts = Opts { oValgrind :: !(Maybe FilePath) @@ -151,8 +153,17 @@ run o prog progArgs = do (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 + environment <- getEnvironment r <- try (withCreateProcess (proc valgrind (memcheckArgs xml <> (prog : progArgs))) + { P.env = Just (("VESTIGIA_OBJECTS", objects) + : filter ((/= "VESTIGIA_OBJECTS") . fst) + environment) } (\_ _ _ ph -> waitForProcess ph)) code <- case r of Left e -> do @@ -160,7 +171,14 @@ run o prog progArgs = do <> show (e :: IOException)) exitWith (ExitFailure 2) Right c -> pure c - report o (Just (exitCode code)) xml + otext <- TIO.readFile objects + os <- case readObjects otext of + Left e -> do + hPutStrLn stderr ("vestigia: " <> objects <> ": " <> e) + exitWith (ExitFailure 2) + Right v -> pure v + removeFile objects + report o (Just (exitCode code)) os xml if temporary then removeFile xml else pure () where exitCode c = case c of @@ -168,10 +186,10 @@ run o prog progArgs = do ExitFailure n -> n readXml :: Opts -> FilePath -> IO () -readXml o xml = report o Nothing xml +readXml o xml = report o Nothing [] xml -report :: Opts -> Maybe Int -> FilePath -> IO () -report o exit xml = do +report :: Opts -> Maybe Int -> [(Integer, T.Text)] -> FilePath -> IO () +report o exit os xml = do bytes <- BS.readFile xml let txt = TE.decodeUtf8With TEE.lenientDecode bytes case readMemcheck txt of @@ -179,7 +197,7 @@ report o exit xml = do hPutStrLn stderr ("vestigia: " <> xml <> ": " <> e) exitWith (ExitFailure 2) Right mc -> do - let tr = trace mc + let tr = withObjects os (trace mc) doc = encode (traceJson (T.pack (showVersion version)) exit tr) maybe (TIO.putStr doc) (\p -> TIO.writeFile p doc) (oOut o) diff --git a/etc/spike/penumbra/default.nix b/etc/spike/penumbra/default.nix @@ -27,6 +27,7 @@ let harnessFor = lib: pkgs.runCommandCC "penumbra-vestigia-harness" { } '' mkdir -p $out/bin cc -O2 -g -o $out/bin/harness ${./harness.c} \ + ${vestigia.packages.${system}.include}/include/vestigia.c \ -I${vestigia.packages.${system}.include}/include \ -I${vestigia.lib.${system}.valgrindDev}/include \ -L${lib}/lib -lpenumbra_audit -Wl,-rpath,${lib}/lib diff --git a/etc/spike/poly1305/Harness.hs b/etc/spike/poly1305/Harness.hs @@ -26,10 +26,10 @@ import Foreign.Ptr (Ptr, castPtr) import Numeric (readHex) import System.Environment (getArgs) -foreign import ccall unsafe "vestigia_secret_c" +foreign import ccall unsafe "vestigia_secret" c_secret :: Ptr () -> CSize -> IO () -foreign import ccall unsafe "vestigia_public_c" +foreign import ccall unsafe "vestigia_public" c_public :: Ptr () -> CSize -> IO () mark :: (Ptr () -> CSize -> IO ()) -> BS.ByteString -> IO () diff --git a/etc/spike/poly1305/default.nix b/etc/spike/poly1305/default.nix @@ -31,7 +31,7 @@ let } '' mkdir -p $out/bin build cp ${./Harness.hs} build/Harness.hs - cp ${vestigia.packages.${system}.include}/include/vestigia-ffi.c build/marks.c + cp ${vestigia.packages.${system}.include}/include/vestigia.c build/marks.c cd build # a small address-space reservation, since memcheck pays for # the runtime's default terabyte on every run, and no diff --git a/etc/spike/secp256k1/Harness.hs b/etc/spike/secp256k1/Harness.hs @@ -28,10 +28,10 @@ import Foreign.Ptr (Ptr, castPtr) import Numeric (readHex) import System.Environment (getArgs) -foreign import ccall unsafe "vestigia_secret_c" +foreign import ccall unsafe "vestigia_secret" c_secret :: Ptr () -> CSize -> IO () -foreign import ccall unsafe "vestigia_public_c" +foreign import ccall unsafe "vestigia_public" c_public :: Ptr () -> CSize -> IO () mark :: (Ptr () -> CSize -> IO ()) -> BS.ByteString -> IO () diff --git a/etc/spike/secp256k1/default.nix b/etc/spike/secp256k1/default.nix @@ -40,7 +40,7 @@ let } '' mkdir -p $out/bin build cp ${./Harness.hs} build/Harness.hs - cp ${vestigia.packages.${system}.include}/include/vestigia-ffi.c build/marks.c + cp ${vestigia.packages.${system}.include}/include/vestigia.c build/marks.c cd build ghc -O2 -rtsopts '-with-rtsopts=-xr1G -V0' Harness.hs marks.c \ -I${vestigia.packages.${system}.include}/include \ diff --git a/include/vestigia-ffi.c b/include/vestigia-ffi.c @@ -1,13 +0,0 @@ -/* - * vestigia-ffi.c: vestigia's two marks as functions, for a harness - * that reaches them through a foreign function interface (a Haskell - * harness's `foreign import ccall`) rather than the header's inline - * definitions. - * - * Copyright (c) 2026 Jared Tobin - * License: MIT - */ -#include "vestigia.h" - -void vestigia_secret_c(void *p, size_t n) { vestigia_secret(p, n); } -void vestigia_public_c(void *p, size_t n) { vestigia_public(p, n); } diff --git a/include/vestigia.c b/include/vestigia.c @@ -0,0 +1,69 @@ +/* + * vestigia.c: the marks vestigia.h declares. + * + * Copyright (c) 2026 Jared Tobin + * License: MIT + * + * A site is reported at a run-time address, and only the loader + * knows where it put the object holding it. On Linux, when the + * environment names a file in VESTIGIA_OBJECTS (vestigia sets it), + * the first secret mark writes there each loaded object's load bias + * and path, so a site can be named by its object and its address in + * it. That needs dl_iterate_phdr, which glibc declares only under + * _GNU_SOURCE, set here before any header can settle the feature + * set; this is why the marks are a compilation unit, not a header. + */ +#ifdef __linux__ +#define _GNU_SOURCE +#include <link.h> +#include <stdio.h> +#include <stdlib.h> +#include <unistd.h> +#endif + +#include <valgrind/memcheck.h> + +#include "vestigia.h" + +#ifdef __linux__ +static int object_(struct dl_phdr_info *info, size_t size, void *data) { + FILE *f = data; + char exe[4096]; + const char *name = info->dlpi_name; + (void)size; + /* the main program is listed unnamed */ + if (name == NULL || name[0] == '\0') { + ssize_t n = readlink("/proc/self/exe", exe, sizeof exe - 1); + if (n <= 0) return 0; + exe[n] = '\0'; + name = exe; + } + fprintf(f, "%lx %s\n", (unsigned long)info->dlpi_addr, name); + return 0; +} + +static void objects_(void) { + static int written = 0; + const char *path; + FILE *f; + if (written) return; + written = 1; + path = getenv("VESTIGIA_OBJECTS"); + if (path == NULL) return; + f = fopen(path, "w"); + if (f == NULL) return; + dl_iterate_phdr(object_, f); + fclose(f); +} +#else +static void objects_(void) {} +#endif + +void vestigia_secret(void *p, size_t n) { + objects_(); + (void)VALGRIND_MAKE_MEM_UNDEFINED(p, n); +} + +void vestigia_public(void *p, size_t n) { + (void)VALGRIND_MAKE_MEM_DEFINED(p, n); +} diff --git a/include/vestigia.h b/include/vestigia.h @@ -12,20 +12,16 @@ * not reported. Outside valgrind both are no-ops. * * Mark a secret after writing it and before the call; mark the - * outputs after the call. + * outputs after the call. A harness compiles vestigia.c beside it, + * which defines both (and serves a foreign function interface as + * well as C). */ #ifndef VESTIGIA_H #define VESTIGIA_H #include <stddef.h> -#include <valgrind/memcheck.h> -static inline void vestigia_secret(void *p, size_t n) { - (void)VALGRIND_MAKE_MEM_UNDEFINED(p, n); -} - -static inline void vestigia_public(void *p, size_t n) { - (void)VALGRIND_MAKE_MEM_DEFINED(p, n); -} +void vestigia_secret(void *p, size_t n); +void vestigia_public(void *p, size_t n); #endif diff --git a/lib/Vestigia/Trace.hs b/lib/Vestigia/Trace.hs @@ -22,6 +22,9 @@ module Vestigia.Trace ( , Site(..) , Trace(..) , trace + , withObjects + , readObjects + , objectOffset , traceJson , traceSummary , classText @@ -30,7 +33,7 @@ module Vestigia.Trace ( import qualified Data.List as L import qualified Data.Map.Strict as M import qualified Data.Text as T -import Numeric (showHex) +import Numeric (readHex, showHex) import Vestigia.Json import Vestigia.Memcheck @@ -58,6 +61,9 @@ data Trace = Trace { , 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)] } deriving (Eq, Show) classText :: Class -> T.Text @@ -100,6 +106,7 @@ trace mc = Trace { , trFaults = [ e | e <- mcErrors mc, not (uninitialised e) , not ("Leak_" `T.isPrefixOf` meKind e) ] + , trObjects = [] } where tagged = @@ -111,6 +118,41 @@ trace mc = Trace { grouped = M.fromListWith (flip (<>)) [ ((c, frIp top), [ (top, e) ]) | ((c, top), e) <- tagged ] +-- | 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. +-- +-- >>> readObjects "7f0000 /lib/libc.so.6\n400000 /bin/harness\n" +-- Right [(8323072,"/lib/libc.so.6"),(4194304,"/bin/harness")] +readObjects :: T.Text -> Either String [(Integer, T.Text)] +readObjects = traverse line . filter (not . T.null . T.strip) . T.lines + where + line l = case T.breakOn " " l of + (b, p) | not (T.null p) + , [ (v, "") ] <- readHex (T.unpack b) -> + Right (v, T.drop 1 p) + _ -> Left ("an unreadable object line: " <> T.unpack l) + +-- | A frame's address in its object, where the harness reported the +-- object: by its path, else by its file name. +objectOffset :: [(Integer, T.Text)] -> Frame -> Maybe Integer +objectOffset os f = do + obj <- frObj f + base <- case [ b | (b, p) <- os, p == obj ] of + (b : _) -> Just b + [] -> case [ b | (b, p) <- os, fileName p == fileName obj ] of + (b : _) -> Just b + [] -> Nothing + if frIp f >= base then Just (frIp f - base) else Nothing + where + fileName p = case reverse (T.splitOn "/" p) of + (x : _) -> x + [] -> p + -- | The report: @vestigia/trace-v1@. traceJson :: T.Text -> Maybe Int -> Trace -> Value traceJson version exit tr = Object @@ -128,26 +170,30 @@ traceJson version exit tr = Object , ("unrelated", Number (toInteger (length (trUnrelated tr)))) , ("faults", Number (toInteger (length (trFaults tr)))) ]) - , ("sites", Array (fmap siteJson (trSites tr))) - , ("unrelated", Array (fmap errorJson (trUnrelated tr))) - , ("faults", Array (fmap errorJson (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))) ] where + os = trObjects tr mc = trRun tr count c = Number (toInteger (length [ () | s <- trSites tr, siClass s == c ])) -siteJson :: Site -> Value -siteJson s = Object $ +siteJson :: [(Integer, T.Text)] -> Site -> Value +siteJson os s = Object $ [ ("class", String (classText (siClass s))) ] - <> frameFields (siFrame s) + <> frameFields os (siFrame s) <> [ ("count", Number (toInteger (siCount s))) - , ("stacks", Array [ Array (fmap frameJson st) + , ("stacks", Array [ Array (fmap (frameJson os) st) | st <- siStacks s ]) ] -errorJson :: MemError -> Value -errorJson e = Object +errorJson :: [(Integer, T.Text)] -> MemError -> Value +errorJson os e = Object [ ("kind", String (meKind e)) , ("what", String (meWhat e)) , ("origin", case meOrigin e of @@ -155,16 +201,17 @@ errorJson e = Object OtherOrigin w _ -> String w NoOrigin -> Null) , ("count", Number (toInteger (meCount e))) - , ("stack", Array (fmap frameJson (meStack e))) + , ("stack", Array (fmap (frameJson os) (meStack e))) ] -frameJson :: Frame -> Value -frameJson = Object . frameFields +frameJson :: [(Integer, T.Text)] -> Frame -> Value +frameJson os = Object . frameFields os -frameFields :: Frame -> [(T.Text, Value)] -frameFields f = +frameFields :: [(Integer, T.Text)] -> Frame -> [(T.Text, Value)] +frameFields os f = [ ("ip", String (hex (frIp f))) , ("object", maybe Null String (frObj f)) + , ("offset", maybe Null (String . hex) (objectOffset os f)) , ("function", maybe Null String (frFn f)) , ("file", maybe Null String (frFile f)) , ("line", maybe Null (Number . toInteger) (frLine f)) diff --git a/nix/lib.nix b/nix/lib.nix @@ -45,7 +45,7 @@ let include = pkgs.runCommand "vestigia-include" { } '' mkdir -p $out/include cp ${src}/include/vestigia.h $out/include/vestigia.h - cp ${src}/include/vestigia-ffi.c $out/include/vestigia-ffi.c + cp ${src}/include/vestigia.c $out/include/vestigia.c ''; # the command, with the valgrind it was built against baked in diff --git a/test/Main.hs b/test/Main.hs @@ -94,6 +94,19 @@ traceTests = testGroup "trace" fmap meKind (trUnrelated tr) @?= [ "UninitValue" ] fmap meKind (trFaults tr) @?= [ "InvalidRead" ] + , testCase "a frame is named by its address in its object" $ do + os <- either assertFailure pure + (readObjects "4000000 /nix/store/x-lib/lib/liba.so\n\ + \0 /nix/store/y-h/bin/harness\n") + let f o i = Frame i (Just o) Nothing Nothing Nothing + objectOffset os (f "/nix/store/x-lib/lib/liba.so" 0x4001234) + @?= Just 0x1234 + -- the same file reached by another path + objectOffset os (f "/other/liba.so" 0x4000010) @?= Just 0x10 + objectOffset os (f "/nix/store/y-h/bin/harness" 0x401000) + @?= Just 0x401000 + objectOffset os (f "/lib/libc.so.6" 0x7000) @?= Nothing + , 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)))