Dynamic analysis: run 4, native seed-to-seed gap
Question
How far apart are a file’s four Yarrow seeds (key1 → iv1 → key2 → iv2) in an undebugged run?
Every earlier capture used breakpoints on each seed, which inflated the gaps to 10–500 ms.
recover’s cost scales with the gap window, so this is the parameter that matters most.
Method: in-process QPC logger, no seed breakpoints
- Image base
0x7FF6A9690000(same boot as run 3). Launch:init "<exe>", "--encryption_path=C:\Users\deadtrace\Desktop\samples -localonly", "<cwd>". - Test set: 20 zero-filled files written with
savedata, 16 ×gap_NN.txt(Spot) and 4 ×gap_db_N.sqlite(Full), all 0x3000 bytes. One more pre-existing file in the folder was also processed, giving 21 files in total. - Hook: the sample’s QPC wrapper
FUN_140078dd4(25 bytes:sub rsp,28; lea rcx,[rsp+30]; call [QueryPerformanceCounter]; mov rax,[rsp+30]; add rsp,28; ret) had its tail at0x140078de3overwritten withmov rax, stub; jmp rax. The stub, at0x20A6DCDF000in an RWX allocation:- reloads the counter value;
- takes the caller’s return address from
[rsp+28]and the thread ID fromgs:[48]; - reserves a 32-byte slot with
lock xaddon a counter at0x20A6DCB0000; - stores (QPC, caller, TID) in that slot and returns the unchanged value.
The overhead is a few nanoseconds per call, with no debug event. Stub bytes:
48 8B 44 24 30 4C 8B 44 24 28 65 44 8B 0C 25 48 00 00 00 49 BA <buf> 41 BB 01 00 00 00 F0 45 0F C1 1A 41 81 FB 88 13 00 00 73 13 49 C1 E3 05 4D 01 D3 49 89 43 40 4D 89 43 48 4D 89 4B 50 48 83 C4 28 C3.
- End trap: the first two bytes of
ntdll!RtlExitUserProcesswere patched toEB FE, so the finished process spins instead of exiting and the buffer can still be read. - Safety gates, as in runs 2 and 3:
FUN_140071620(WMI shadow-copy executor) was forced to return.- The root-folder gate
FUN_140073950confirmed the only root wasC:\Users\deadtrace\Desktop\samples. After that, all breakpoints were deleted.
- QPC frequency:
KUSER_SHARED_DATA.QpcFrequency(0x7FFE0300) = 10,000,000, so seed string = QPC × 100.
What actually happened during the run
- Detach attempt: I detached x64dbg at the gate so the run would be fully native, then
re-attached to read the buffer.
- What happened: the counter stayed at 2 entries and no attach break, pause or breakpoint event arrived for several minutes.
- Cause: a separate session reported that ScyllaHide had shown a modal dialog on re-attach. Its hooks from the first session were still in the process. While that dialog is open, x64dbg stops handling debug events.
- Release: the process came free when the dialog was dismissed, at about the same moment as my two
resumeallthreads. “Detach leaves the process frozen”, my first explanation, is therefore probably wrong. - Effect: no seed data came from a detached phase.
- Attached phase: the encryption then ran attached but with no breakpoints at all until the end trap. The x64dbg event log for that window shows 2 thread creations, 6 thread exits, 5 DLL loads, no exceptions and no breakpoint hits. Seed-to-seed work (Yarrow-256, ciphers) is pure user-mode computation, which an idle debugger does not slow down.
- Residual perturbation: the only possible one is a thread or DLL debug event landing inside a gap. That could explain at most a few of the outliers below, not the floor.
- Verification:
gap_00.txt.akiraandgap_db_0.sqlite.akirawere pulled to the host (Ghidra raw import +read_memory, saved as06-decryptor/e2e-native/).- Both decrypt to all-zero 12,288-byte originals with the logged values × 100 as
--seeds. - So the hook recorded exactly the seeds the sample used.
- Timer integrity check (ScyllaHide anti-timing). A fresh process was started with the same
debugger profile and stopped at the entry point (
-ladded, gate and WMI breakpoints armed; nothing ran past entry). Its timer path:- the sample’s IAT
0x1400c42f0points to thekernel32!QueryPerformanceCounterstub, which isjmp [kernel32 IAT]; - that leads to
ntdll!RtlQueryPerformanceCounter(0x7FF868390160), with its original prologue (mov [rsp+8],rbx; push rdi; sub rsp,20; mov r8b,[7FFE03C6]); ntdll!NtQueryPerformanceCounteris the untouched syscall stub (mov r10,rcx; mov eax,31);SharedUserData+0x3C6= 0x83: QPC bypass is enabled, so the counter is read in user mode (TSC-based) without a syscall.
None of these functions is hooked, so the logged values are real time.
- the sample’s IAT
Results
129 QPC reads in total. Each caller appears in its own role:
| Caller (static) | Role | Count |
|---|---|---|
0x14004d505 / 0x14004e06f |
main start / end timer | 1 / 1 |
0x14007bc49 |
RSA padding Yarrow seed (FUN_14007bbe0) |
1 |
0x1400aff57 |
per-file coroutine start (FUN_1400ae840) |
21 |
0x14007b237 |
per-value Yarrow seed (FUN_14007b1f0) |
84 = 21 × 4 |
0x1400b1c99 |
per-file coroutine end | 21 |
All 84 seeds are in increasing order. Two encryptor threads (TID 0x1F9C, 0x1B38) interleave. Gap sizes are in 100 ns ticks:
| Gap | n | Min | Median | Mean | Max |
|---|---|---|---|---|---|
| file start → key1 | 21 | 1 | 2 | 3.1 | 18 |
| key1 → iv1 | 21 | 3,916 | 4,119 | 4,205 | 4,692 |
| iv1 → key2 | 21 | 3,887 | 4,047 | 4,894 | 14,397 |
| key2 → iv2 | 21 | 3,889 | 4,121 | 5,424 | 27,109 |
| key1 → iv2 (whole file) | 21 | 11,773 | 12,539 | 14,522 | 36,268 |
| same thread: iv2 → next file’s key1 | 19 | 15,024 | 16,513 | 17,809 | 32,634 |
| file start → file end | 21 | 25,644 | 27,619 | 31,842 | 66,591 |
Sorted key2 → iv2 gaps: 3,889 3,892 3,904 3,911 3,913 3,922 3,925 3,945 4,040 4,094 4,121 4,231 4,244 4,255 4,296 4,308 4,344 5,073 5,078 7,404 27,109.
Interpretation
- The native gap is about 0.4 ms, not milliseconds. The floor of ~3,890 ticks (389 µs) is the cost of one Yarrow-256 seeding (1,500 SHA-256 iterations plus AES key setups) on this VM’s CPU. It scales with the victim’s CPU speed.
- Tail: 18 of 21 key2 → iv2 gaps are under 5,100 ticks. The three slow gaps (7,404, 14,397, 27,109) coincide with the other encryptor thread seeding at the same moment on a 2-vCPU VM; 26 seeds from the other thread fall inside some file’s key1..iv2 window.
- key1 is taken 0.1–1.8 µs after the per-file coroutine starts, so the file’s processing start time is effectively the key1 seed.
- Whole-run density: the 21 files’ 84 seeds span 336,236 ticks (33.6 ms). A solved file brackets its neighbours to within a few milliseconds.
Effect on recover
Pairs = key2 window (ticks) × gap span (ticks), at ~150,000 pairs/s on the 20-thread analysis host.
| key2 uncertainty | Gap 3,800–5,200 (span 1,400, covers 18/21) | Gap 3,800–8,000 (span 4,200, covers 20/21) | Gap 3,800–28,000 (span 24,200, covers 21/21) |
|---|---|---|---|
| ±0.5 ms (10,000 ticks) | 1.4·10⁷ pairs, ~1.6 min | 4.2·10⁷, ~4.7 min | 2.4·10⁸, ~27 min |
| ±5 ms (100,000) | 1.4·10⁸, ~16 min | 4.2·10⁸, ~47 min | 2.4·10⁹, ~4.5 h |
| ±50 ms (10⁶) | 1.4·10⁹, ~2.6 h | 4.2·10⁹, ~7.8 h | 2.4·10¹⁰, ~45 h |
Practical recipe:
- Search the narrow gap window first.
- Widen only if nothing is found.
- After the first hit, use the solved file’s seeds to bracket the next file to a few milliseconds, with the measured inter-file spacing.
The uptime estimate (boot time vs. file timestamps) remains the dominant cost; the gap no longer is.
Checked on the native outputs (artifacts/seed-gap-recover-demo-2026-10-11.txt). Both runs used a
key2 window of ±2,500 ticks, a gap window of 3,800–8,000 and 16 known zero bytes:
| File | Candidate pairs | Result |
|---|---|---|
gap_00.txt.akira (Spot; its gap is the 7,404-tick outlier) |
21,009,201 | true key2/iv2 pair found in 70 s; decrypted all zero |
gap_db_0.sqlite.akira (Full; one chunk under 0xFFFF, so KCipher-2 only) |
21,009,201 | found in 85 s; decrypted all zero |
recover now defaults to --gap 3800:8000, and the default ChaCha-stage windows are widened to at least
that range. test_real_outputs.py includes both native files (12/12 pass).
Artifacts
artifacts/seed_gap_qpc_buffer.bin: raw hook buffer (counter + 129 × 32-byte entries).04-dynamic/seed-gap/qpc_log.csv: every QPC read with seed string, caller and TID.04-dynamic/seed-gap/seed_gap_files.csv: per-file seeds and gaps.06-decryptor/e2e-native/: two native-run outputs, verified against the logged seeds.artifacts/seed-gap-recover-demo-2026-10-11.txt:recoveron the native outputs with measured-gap windows.
VM state
- The sample exited cleanly after
RtlExitUserProcesswas restored. - The samples folder now holds 21 more
.akirafiles (gap_*) and a new log. Revert recommended. - Desktop has
gap_buffer.binandgap_buffer_final.bin. - Ghidra
/decrypt_testhas four more raw imports (gap_buffer*.bin,gap_00.txt.akira,gap_db_0.sqlite.akira) that can be deleted.