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:
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)))