vestigia

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

commit dbca2b60ce279520826d4c578fcab75e60452541
parent 9b367ba45596268931c1e3728400053b8fe333ee
Author: Jared Tobin <jared@jtobin.io>
Date:   Wed, 21 Oct 2026 08:56:49 +0400

plugin: a guest that starts a second thread is stopped, its dump refused

The plugin's state is unlocked and its successor fingerprints follow
one stream of instructions, so it records one guest thread. A guest
that started another (QEMU's user mode gives each thread a vCPU) was
recorded anyway, by threads racing on that state: a dump that could
be wrong anywhere, or a crash.

The plugin now counts vCPUs as they start. At the second, it writes a
dump holding only `T 2`, says why on stderr, and exits; readDump
refuses such a dump. The probe check runs a guest that starts a
thread and holds record and diff to failing on it.

Diffstat:
MREADME.md | 5+++--
Aetc/probe/threads.c | 16++++++++++++++++
Mlib/Vestigia/Diff.hs | 7++++++-
Mnix/probe.nix | 18++++++++++++++++++
Mplugin/vestigia-qemu.c | 24+++++++++++++++++++++++-
Mtest/Main.hs | 3+++
6 files changed, 69 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md @@ -137,8 +137,9 @@ ppad-poly1305, and ppad-secp256k1. * valgrind and QEMU's user mode run on Linux; elsewhere vestigia only reads their reports. QEMU traces other ISAs differentially, 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, argument lengths included. * A GHC harness wants `-with-rtsopts=-xr1G -V0`, against the runtime's terabyte reservation and its interval timer. * Source lines need line tables. 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/lib/Vestigia/Diff.hs b/lib/Vestigia/Diff.hs @@ -103,7 +103,9 @@ data Dump = Dump { -- | 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. readDump :: T.Text -> Either String Dump readDump t = go (T.lines t) M.empty M.empty where @@ -124,6 +126,9 @@ readDump t = go (T.lines t) M.empty M.empty k <- unhex to c <- unhex n go rest is (M.insertWith M.union f (M.singleton k c) es) + [ "T", _ ] -> + Left "the guest started a second thread, which a trace \ + \cannot record: the run was stopped" [] -> go rest is es _ -> Left ("an unreadable dump line: " <> T.unpack l) unhex x = case readHex (T.unpack x) of diff --git a/nix/probe.nix b/nix/probe.nix @@ -19,6 +19,11 @@ let readelf -h $out/bin/probe | grep -q 'Type: *EXEC' ''; + 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" ]; @@ -58,5 +63,18 @@ pkgs.runCommand "vestigia-probe-check" { check ${diff}/diff.json \ '[.origins[] | [.class, .frames[0].function]] | unique' \ '${builtins.toJSON expected.diff}' + + # a guest that starts a second thread is stopped, and its dump + # refused + v=${vestigia.vestigia}/bin/vestigia + 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 @@ -23,6 +23,8 @@ * I <pc> <count> <accesses> <fingerprint> <order> <object> <address> * E <from> <to> <count> * + * or, for a guest that started a second thread, the line `T 2` alone. + * * in hex, where <object> is the file the instruction's bytes were * 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 @@ -30,7 +32,9 @@ * 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 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> @@ -38,6 +42,7 @@ #include <stdio.h> #include <stdlib.h> #include <string.h> +#include <unistd.h> #include <qemu-plugin.h> @@ -57,6 +62,7 @@ typedef struct Insn { 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 const uint64_t fnv_offset = 0xcbf29ce484222325ULL; static const uint64_t fnv_prime = 0x100000001b3ULL; @@ -108,6 +114,21 @@ static void on_mem(unsigned int vcpu, qemu_plugin_meminfo_t info, i->accesses++; } +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) { + 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) { size_t n = qemu_plugin_tb_n_insns(tb); (void)id; @@ -277,6 +298,7 @@ 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); + 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 @@ -270,6 +270,9 @@ 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" ] where -- 0x10 is a branch run four times, taken twice in A and three