vestigia

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

commit fb627c0089ad2134f20072b1ba531d981cba59bf
parent e07799b18a84745a35c7ebd88f25d97cd4a1c265
Author: Jared Tobin <jared@jtobin.io>
Date:   Mon, 19 Oct 2026 14:19:44 +0400

merge impl/sound: outcome order compared, runs that prove nothing refused

- The QEMU plugin fingerprints, per instruction, the order of what
  followed it, and the diff compares it: a branch run as often, taking
  each outcome as often, in another order, is a control origin.
  Counting outcomes alone missed it.
- A taint run that marked no secret is refused, as is a dump in which
  no instruction ran: neither records a run, and both had read clean.
- Runs are made repeatable or refused. vestigia builds QEMU's user mode
  alone, patched so a guest's getrandom, answered by the host as
  before (its flags, length, and errors the kernel's), is filled from
  the generator -seed fixes; record refuses to run when it cannot turn
  address randomisation off.
- The README states the designed-run contract: the same harness and
  arguments, only the secret, chosen to reach a path.

Checked against the previous vestigia on pollux with a probe: an
order-only branch is now an origin (was missed), a branch on getrandom
is steady across runs and the control (15 instructions differed), an
invalid getrandom flag still fails with EINVAL, and an unmarked
harness's run is refused (it read clean). 26 tests pass.

Diffstat:
MCHANGELOG | 7+++++--
MREADME.md | 36+++++++++++++++++++++++-------------
Mapp/Main.hs | 16++++++++++++----
Minclude/vestigia.c | 14++++++++------
Mlib/Vestigia/Diff.hs | 45++++++++++++++++++++++++++++++---------------
Mnix/lib.nix | 10++++++++--
Anix/qemu-getrandom.patch | 25+++++++++++++++++++++++++
Mplugin/vestigia-qemu.c | 29++++++++++++++++++-----------
Mtest/Main.hs | 73+++++++++++++++++++++++++++++++++++++++++++++++--------------------------
9 files changed, 176 insertions(+), 79 deletions(-)

diff --git a/CHANGELOG b/CHANGELOG @@ -13,11 +13,14 @@ 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, and what ran + 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 and the guest's entropy seeded. + randomisation off (or not at all) and the guest's entropy, + its getrandom included, seeded. 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. diff --git a/README.md b/README.md @@ -45,9 +45,11 @@ 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. 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. +timed by censor unchanged. On Linux the first secret mark of any +bytes also writes where the loader put each object, for `vestigia +run` to name every site by its object and its address in it. A run +that marked nothing found nothing because nothing was secret, and +`run` refuses it. ### Taint: `vestigia run` @@ -99,7 +101,8 @@ address 0x152eb Fq::sqrt_ratio_zeta invsqrt.rs:117 ... ``` An *origin* is a branch whose outcomes differ in proportion between -runs (a loop whose trip count the secret sets is one) or an +runs (a loop whose trip count the secret sets is one), a branch +taking the same outcomes as often in another order, or an instruction run equally often whose addresses differ. Each origin names the runs that behaved alike there, grouped, and the runs that never reached it, each run by its dump's file name: which secrets @@ -111,18 +114,25 @@ chosen secrets exercised, to set against what the code could execute. `--object` keeps origins, what ran downstream, and what was executed, to objects whose path contains the given text. -`--designed DUMP` names a run aimed at a particular path. It differs -from the others in where it goes, not only in the secret, so it -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. +`--designed DUMP` names a run aimed at a particular path: the same +harness with the same arguments, differing from the others only in a +secret chosen to reach that path. A run whose public inputs differ +is not designed but another experiment, and its differences say +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: +it goes where the others do not, so an instruction's addresses +differing in it need not mean an address computed from the secret. A difference means the secret only if nothing else differs between runs. `record` turns address-space randomisation off for itself and -QEMU, and seeds the guest's entropy, so runs lay memory out alike -and a hash table seeded from the system's randomness is laid out -alike too. `--control DUMP` names a run repeating the first run's +QEMU, refusing to run where it cannot, and seeds the guest's +entropy, so runs lay memory out alike and a hash table seeded from +the system's randomness is laid out alike too. The pinned QEMU +fills the guest's `getrandom` from that seeded generator, the host +still checking its flags and choosing its length +(`nix/qemu-getrandom.patch`); a guest reading `/dev/urandom` still +reads the host's. `--control DUMP` names a run repeating the first run's secret: whatever differs between the two (a clock read, a scheduler's choice) is *unsteady*, counted and kept out of the origins. diff --git a/app/Main.hs b/app/Main.hs @@ -14,7 +14,7 @@ module Main ( ) where import Control.Exception (IOException, try) -import Control.Monad (unless) +import Control.Monad (unless, when) import qualified Data.ByteString as BS import Data.Version (showVersion) import qualified Data.Text as T @@ -183,6 +183,13 @@ run o prog progArgs = do 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 @@ -222,9 +229,10 @@ record o prog progArgs = do -- same entropy: a hash table seeded from the operating system's -- randomness is laid out anew in every process fixed <- fixLayout - unless fixed $ hPutStrLn stderr - "vestigia: address randomisation is still on; runs may differ \ - \in layout, and so everywhere" + unless fixed $ 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" plugin <- tool (oPlugin o) "VESTIGIA_PLUGIN" "libvestigia-qemu.so" r <- try (withCreateProcess diff --git a/include/vestigia.c b/include/vestigia.c @@ -7,11 +7,13 @@ * 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. + * the first secret mark of any bytes 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 the file was written is also how + * vestigia knows a secret was marked at all. 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 @@ -60,7 +62,7 @@ static void objects_(void) {} #endif void vestigia_secret(void *p, size_t n) { - objects_(); + if (n > 0) objects_(); (void)VALGRIND_MAKE_MEM_UNDEFINED(p, n); } diff --git a/lib/Vestigia/Diff.hs b/lib/Vestigia/Diff.hs @@ -17,11 +17,13 @@ -- branch whose outcomes differ in proportion between runs had its -- own condition depend on the secret, and is a `control` origin -- (a loop whose trip count the secret sets is one, though it also --- runs a different number of times). An instruction run equally --- often in every run whose addresses differ computed one from the --- secret: an `address` origin. Every other instruction run a --- different number of times sits where control had already --- diverged: it is downstream, and is counted rather than listed. +-- runs a different number of times). So is a branch run as often, +-- 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 +-- other instruction run a different number of times sits where +-- control had already diverged: it is downstream. -- -- Unlike memcheck's taint, this sees what control flow does to -- data as well as what data does: a value chosen by a secret @@ -83,6 +85,8 @@ data Insn = Insn { , inCount :: !Integer , inAccesses :: !Integer , inFingerprint :: !T.Text + -- | what followed it, in order, fingerprinted + , inOrder :: !T.Text , inObject :: !(Maybe T.Text) , inOffset :: !Integer } deriving (Eq, Show) @@ -94,15 +98,21 @@ data Dump = Dump { , dpTransfers :: !(M.Map Integer (M.Map Integer Integer)) } deriving (Eq, Show) --- | Read a plugin 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. readDump :: T.Text -> Either String Dump readDump t = go (T.lines t) M.empty M.empty where go ls is es = case ls of - [] -> pure (Dump is es) + [] + | 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, obj, off ] -> do + [ "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 @@ -189,7 +199,7 @@ diffDesigned cs ds xs = Divergence { ta = M.lookup pc (dpTransfers a) tb = M.lookup pc (dpTransfers b) , fmap sig ia /= fmap sig ib || ta /= tb ] - sig i = (inCount i, inFingerprint i) + sig i = (inCount i, inFingerprint i, inOrder i) origins = [ o | o@(_, i) <- controls <> addresses , not (inPc i `S.member` unsteady) ] @@ -202,9 +212,9 @@ diffDesigned cs ds xs = Divergence { -- declared runs speak to an address. behaviourAt o pc = case o of ControlOrigin -> - ( classes proportional + ( classes outcomesAlike [ (k, (inCount i, M.findWithDefault M.empty pc - (dpTransfers d))) + (dpTransfers d), inOrder i)) | (k, d, i) <- ranIn runs ] , absentIn runs ) AddressOrigin -> @@ -236,10 +246,11 @@ diffDesigned cs ds xs = Divergence { steadyDeclared = S.filter (alike . countsIn ds) (S.unions (fmap (M.keysSet . dpInsns) ds)) - -- an instruction's run count and transfers, in each run that ran - -- it + -- an instruction's run count, transfers, and the order of what + -- followed it, in each run that ran it ranAt pc = - [ (inCount i, M.findWithDefault M.empty pc (dpTransfers d)) + [ ( inCount i, M.findWithDefault M.empty pc (dpTransfers d) + , inOrder i ) | d <- runs, Just i <- [ M.lookup pc (dpInsns d) ] , inCount i > 0 ] transferSources = S.unions (fmap (M.keysSet . dpTransfers) runs) @@ -250,8 +261,12 @@ diffDesigned cs ds xs = Divergence { M.keysSet ts == M.keysSet ts' && and (M.elems (M.intersectionWith (\e e' -> e * c' == e' * c) ts ts')) + -- outcomes alike: each target taken in the same proportion and, + -- where it ran as often, in the same order + outcomesAlike (c, ts, o) (c', ts', o') = + proportional (c, ts) (c', ts') && (c /= c' || o == o') divergent pc = case ranAt pc of - (r : rest) -> not (all (proportional r) rest) + (r : rest) -> not (all (outcomesAlike r) rest) [] -> False controlPcs = S.filter divergent transferSources diff --git a/nix/lib.nix b/nix/lib.nix @@ -22,8 +22,14 @@ let # 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 - qemu = pkgs.qemu; + # system's ISA under it: 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"; diff --git a/nix/qemu-getrandom.patch b/nix/qemu-getrandom.patch @@ -0,0 +1,25 @@ +QEMU's user mode answers a guest's getrandom from the host, which +-seed does not reach: two runs of one program draw different entropy, +and every instruction computing on it differs between them. The host +still answers the call, so its flags are checked and its length and +errors chosen as the kernel would; the bytes it returned are then +drawn from the guest generator, which -seed makes deterministic and +which, unseeded, draws from the host. + +--- a/linux-user/syscall.c ++++ b/linux-user/syscall.c +@@ -10899,6 +10899,14 @@ + return -TARGET_EFAULT; + } + ret = get_errno(getrandom(p, arg2, arg3)); ++ if (ret > 0) { ++ /* ++ * The host checked the flags and chose the length; the ++ * bytes come from the guest generator, which -seed makes ++ * deterministic. ++ */ ++ qemu_guest_getrandom_nofail(p, ret); ++ } + unlock_user(p, arg1, ret); + return ret; + #endif diff --git a/plugin/vestigia-qemu.c b/plugin/vestigia-qemu.c @@ -7,18 +7,20 @@ * * For every guest instruction executed it records how many times it * ran, every non-sequential transfer out of it (a branch taken, a - * call, a jump, a return) with its count, and an order-sensitive - * fingerprint of the data addresses it touched. Two runs of a - * program that differ only in a secret then differ exactly where - * the execution depended on the secret: a transfer whose counts - * differ is a secret-dependent branch, and an instruction run as + * call, a jump, a return) with its count, an order-sensitive + * fingerprint of the data addresses it touched, and an order- + * sensitive fingerprint of the instructions that followed it. Two + * runs of a program that differ only in a secret then differ exactly + * where the execution depended on the secret: a transfer whose counts + * differ is a secret-dependent branch, as is one taking the same + * transfers as often in another order, and an instruction run as * often but touching other addresses computed one from the secret. * Nothing needs aligning, and no taint is propagated, so effects of * control flow are seen as well as those of data flow. * * At exit it writes, to the file named by its `out` argument: * - * I <pc> <count> <accesses> <fingerprint> <object> <offset> + * I <pc> <count> <accesses> <fingerprint> <order> <object> <offset> * E <from> <to> <count> * * in hex, where <object> is the file the instruction's bytes were @@ -43,6 +45,7 @@ typedef struct Insn { uint64_t count; uint64_t accesses; uint64_t fingerprint; + uint64_t order; /* the successors, in the order they followed */ GHashTable *succ; /* target pc -> count, for transfers */ } Insn; @@ -61,6 +64,7 @@ static Insn *insn_at(uint64_t pc, uint64_t size, uintptr_t haddr) { i->size = size; i->haddr = haddr; i->fingerprint = fnv_offset; + i->order = fnv_offset; 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); @@ -82,8 +86,10 @@ static void transfer(Insn *from, uint64_t to) { static void on_exec(unsigned int vcpu, void *udata) { Insn *i = udata; (void)vcpu; - if (prev != NULL && i->pc != prev->pc + prev->size) - transfer(prev, i->pc); + if (prev != NULL) { + prev->order = (prev->order ^ i->pc) * fnv_prime; + if (i->pc != prev->pc + prev->size) transfer(prev, i->pc); + } prev = i; i->count++; } @@ -169,12 +175,13 @@ static void at_exit(qemu_plugin_id_t id, void *p) { const Region *r = region_of(rs, i->haddr); if (r != NULL) fprintf(out, "I %" PRIx64 " %" PRIx64 " %" PRIx64 " %016" PRIx64 - " %s %" PRIx64 "\n", i->pc, i->count, i->accesses, - i->fingerprint, r->path, + " %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); else fprintf(out, "I %" PRIx64 " %" PRIx64 " %" PRIx64 " %016" PRIx64 - " - 0\n", i->pc, i->count, i->accesses, i->fingerprint); + " %016" PRIx64 " - 0\n", i->pc, i->count, i->accesses, + i->fingerprint, i->order); GHashTableIter jt; gpointer tk, tn; g_hash_table_iter_init(&jt, i->succ); diff --git a/test/Main.hs b/test/Main.hs @@ -154,9 +154,9 @@ diffTests = testGroup "diff" -- a repeat of A whose 0x20 touched other addresses: an -- entropy source, not the secret c <- either assertFailure pure (readDump (T.unlines - [ "I 10 4 0 aaaa /lib.so 10", "E 10 30 2" - , "I 20 4 4 ffff /lib.so 20", "I 30 2 0 cccc /lib.so 30" - , "I 40 1 1 dddd /lib.so 40" ])) + [ "I 10 4 0 aaaa 0 /lib.so 10", "E 10 30 2" + , "I 20 4 4 ffff 0 /lib.so 20", "I 30 2 0 cccc 0 /lib.so 30" + , "I 40 1 1 dddd 0 /lib.so 40" ])) let dv = diffControlled [ c ] [ a, b ] fmap (\(o, i) -> (o, inPc i)) (dvOrigins dv) @?= [ (ControlOrigin, 0x10) ] @@ -168,9 +168,9 @@ diffTests = testGroup "diff" -- a run designed onto another path: 0x10 taken every time, and -- 0x20's addresses its own x <- either assertFailure pure (readDump (T.unlines - [ "I 10 4 0 aaaa /lib.so 10", "E 10 30 4" - , "I 20 4 4 9999 /lib.so 20", "I 30 4 0 cccc /lib.so 30" - , "I 50 1 0 eeee /lib.so 50" ])) + [ "I 10 4 0 aaaa 0 /lib.so 10", "E 10 30 4" + , "I 20 4 4 9999 0 /lib.so 20", "I 30 4 0 cccc 0 /lib.so 30" + , "I 50 1 0 eeee 0 /lib.so 50" ])) let dv = diffDesigned [] [ a, a ] [ x ] fmap (\(o, i) -> (o, inPc i)) (dvOrigins dv) @?= [ (ControlOrigin, 0x10) ] @@ -185,8 +185,8 @@ diffTests = testGroup "diff" a <- either assertFailure pure (readDump dumpA) b <- either assertFailure pure (readDump dumpB) x <- either assertFailure pure (readDump (T.unlines - [ "I 10 4 0 aaaa /lib.so 10", "E 10 30 4" - , "I 20 4 4 9999 /lib.so 20", "I 30 4 0 cccc /lib.so 30" ])) + [ "I 10 4 0 aaaa 0 /lib.so 10", "E 10 30 4" + , "I 20 4 4 9999 0 /lib.so 20", "I 30 4 0 cccc 0 /lib.so 30" ])) -- at the branch, the first and third runs alike; the designed -- run is a control origin's to group, and the address origin -- speaks only of the declared runs @@ -203,14 +203,30 @@ diffTests = testGroup "diff" , testCase "downstream is kept to the objects under audit" $ do a <- either assertFailure pure (readDump dumpA) b <- either assertFailure pure (readDump (T.unlines - [ "I 10 4 0 aaaa /lib.so 10", "E 10 30 3" - , "I 20 4 4 bbbb /lib.so 20", "I 30 3 0 cccc /lib.so 30" - , "I 40 1 1 dddd /lib.so 40", "I 60 2 0 ffff /libc.so 60" ])) + [ "I 10 4 0 aaaa 0 /lib.so 10", "E 10 30 3" + , "I 20 4 4 bbbb 0 /lib.so 20", "I 30 3 0 cccc 0 /lib.so 30" + , "I 40 1 1 dddd 0 /lib.so 40", "I 60 2 0 ffff 0 /libc.so 60" ])) let dv = diffDumps [ a, b ] fmap inPc (dvDownstream dv) @?= [ 0x30, 0x60 ] fmap inPc (dvDownstream (restrictTo [ "lib.so" ] dv)) @?= [ 0x30 ] + , testCase "a branch taken as often, in another order, is an origin" $ do + -- 0x10 runs twice and falls through once and jumps once in both + -- runs, but in the other order + a <- either assertFailure pure (readDump (T.unlines + [ "I 10 2 0 aaaa 1111 /lib.so 10", "E 10 30 1" + , "I 20 1 0 bbbb 0 /lib.so 20", "I 30 1 0 cccc 0 /lib.so 30" ])) + b <- either assertFailure pure (readDump (T.unlines + [ "I 10 2 0 aaaa 2222 /lib.so 10", "E 10 30 1" + , "I 20 1 0 bbbb 0 /lib.so 20", "I 30 1 0 cccc 0 /lib.so 30" ])) + fmap (\(o, i) -> (o, inPc i)) (dvOrigins (diffDumps [ a, b ])) + @?= [ (ControlOrigin, 0x10) ] + M.lookup (ControlOrigin, 0x10) (dvBehaviour (diffDumps [ a, b, a ])) + @?= Just ([ [ 0, 2 ], [ 1 ] ], []) + -- in the same order, nothing + dvOrigins (diffDumps [ a, a ]) @?= [] + , testCase "identical runs have no origins" $ do a <- either assertFailure pure (readDump dumpA) let dv = diffDumps [ a, a ] @@ -220,8 +236,8 @@ diffTests = testGroup "diff" , testCase "what any run executed is kept per object" $ do a <- either assertFailure pure (readDump dumpA) b <- either assertFailure pure (readDump (T.unlines - [ "I 10 4 0 aaaa /lib.so 10", "I 50 1 0 eeee /lib.so 50" - , "I 60 1 0 ffff /libc.so 60", "I 70 0 0 0000 /lib.so 70" ])) + [ "I 10 4 0 aaaa 0 /lib.so 10", "I 50 1 0 eeee 0 /lib.so 50" + , "I 60 1 0 ffff 0 /libc.so 60", "I 70 0 0 0000 0 /lib.so 70" ])) let dv = diffDumps [ a, b ] dvExecuted dv @?= M.fromList [ ("/lib.so", S.fromList [ 0x10, 0x20, 0x30, 0x40, 0x50 ]) @@ -229,32 +245,37 @@ diffTests = testGroup "diff" M.keys (dvExecuted (restrictTo [ "lib.so" ] dv)) @?= [ "/lib.so" ] + , testCase "a dump in which nothing ran is refused" $ do + isLeft (readDump "") @? "an empty dump accepted" + isLeft (readDump "I 10 0 0 aaaa 0 /lib.so 10\n") + @? "a dump of instructions never run accepted" + , testCase "an unreadable line is refused" $ - isLeft (readDump "I 10 zz 0 f /lib.so 0\n") @? "accepted" + isLeft (readDump "I 10 zz 0 f 0 /lib.so 0\n") @? "accepted" ] where -- 0x10 is a branch run four times, taken twice in A and three -- times in B; 0x20 reads other addresses in B; 0x30, inside the -- branch, runs a different number of times; 0x40 is the same dumpA = T.unlines - [ "I 10 4 0 aaaa /lib.so 10", "E 10 30 2" - , "I 20 4 4 bbbb /lib.so 20", "I 30 2 0 cccc /lib.so 30" - , "I 40 1 1 dddd /lib.so 40" ] + [ "I 10 4 0 aaaa 0 /lib.so 10", "E 10 30 2" + , "I 20 4 4 bbbb 0 /lib.so 20", "I 30 2 0 cccc 0 /lib.so 30" + , "I 40 1 1 dddd 0 /lib.so 40" ] dumpB = T.unlines - [ "I 10 4 0 aaaa /lib.so 10", "E 10 30 3" - , "I 20 4 4 eeee /lib.so 20", "I 30 3 0 cccc /lib.so 30" - , "I 40 1 1 dddd /lib.so 40" ] + [ "I 10 4 0 aaaa 0 /lib.so 10", "E 10 30 3" + , "I 20 4 4 eeee 0 /lib.so 20", "I 30 3 0 cccc 0 /lib.so 30" + , "I 40 1 1 dddd 0 /lib.so 40" ] -- a loop at 0x10 the secret runs 5 or 9 times, its back edge to -- 0x14 taken every time but the last; 0x18, in the body, always -- jumps back to 0x10 loopA = T.unlines - [ "I 10 5 0 aaaa /lib.so 10", "E 10 14 4" - , "I 14 4 0 bbbb /lib.so 14" - , "I 18 4 0 cccc /lib.so 18", "E 18 10 4" ] + [ "I 10 5 0 aaaa 0 /lib.so 10", "E 10 14 4" + , "I 14 4 0 bbbb 0 /lib.so 14" + , "I 18 4 0 cccc 0 /lib.so 18", "E 18 10 4" ] loopB = T.unlines - [ "I 10 9 0 aaaa /lib.so 10", "E 10 14 8" - , "I 14 8 0 bbbb /lib.so 14" - , "I 18 8 0 cccc /lib.so 18", "E 18 10 8" ] + [ "I 10 9 0 aaaa 0 /lib.so 10", "E 10 14 8" + , "I 14 8 0 bbbb 0 /lib.so 14" + , "I 18 8 0 cccc 0 /lib.so 18", "E 18 10 8" ] symbolTests :: TestTree symbolTests = testGroup "symbol"