akira_decrypt.py
Decryptor for .akira files produced by sample
101bd45ab673fd6547386dff4339f1649e61b9f5c318260d365f766fbdc2d3c9.
Pure Python 3.8+, standard library only. Optional: pip install numpy numba turns on the compiled
stage-1 search in akira_fast.py (see Speed); without them everything still works, only slower.
The sample seals each file’s keys with an RSA-4096 key whose private half only the operator holds. This tool decrypts a file once the per-file keys are known from any of three sources:
| Source | Option | When you have it |
|---|---|---|
| Raw keys | --keys key1:iv1:key2:iv2 (hex, 32/16/16/16 bytes) |
Keys recovered from memory, an EDR/forensic capture, or a seed search |
| Yarrow seeds | --seeds s1,s2,s3,s4 |
The four QPC-derived seed strings, in order key1, iv1, key2, iv2 |
| Operator private key | --private-key key.pem (PEM/DER, PKCS#1 or PKCS#8) |
If the key ever leaks; decrypts the 512-byte footer and reads everything from it, works on whole folders |
Without the private key, mode and percent are inferred the same way the sample chose
them: the 153 database extensions mean Full, the 20 VM/disk extensions or a size
above 2,000,000 bytes mean Part, and anything else means Spot. Use --percent if the
attacker passed --encryption_percent, or --mode to force a mode.
Usage
python akira_decrypt.py selftest
python akira_decrypt.py info report.docx.akira
python akira_decrypt.py decrypt report.docx.akira --keys <k1>:<iv1>:<k2>:<iv2> --out report.docx
python akira_decrypt.py decrypt report.docx.akira --seeds 4247816085700,4259498741100,4277557538300,4287007536900
python akira_decrypt.py decrypt D:\share --private-key operator.pem
python akira_decrypt.py decrypt big.sqlite --journal big.sqlite.arika --keys ... # interrupted file
The output defaults to the name without .akira, and an existing file is never
overwritten unless you pass --force. Work on copies of the encrypted files.
Key recovery without the RSA key (recover)
Every key and IV the sample uses is the first 16 bytes of a fresh Yarrow-256 draw. Each
draw is seeded only with QPC × 100, which on a 10 MHz QPC is the machine’s uptime in
nanoseconds, written as a decimal string. recover brute-forces those seeds:
- Stage 1 (KCipher-2): for every key2 seed in the window and every candidate gap to
the iv2 seed, test whether KCipher-2 turns the first ciphertext bytes into the known
header. The header comes from the extension table,
--known-hexor--known-file; at least 7 bytes are needed (a partial last word is masked, so%PDF-1.works). That is enough for Spot files, where ChaCha is never used. For ZIP-based files (DOCX/XLSX), the first local header can be rebuilt from the central directory when it sits in plaintext (Spot leaves the tail unencrypted): see../04-dynamic/run5-blind/run5_blind_recover.py. - Stage 2 (ChaCha): for files with ChaCha chunks (Full, Part, and Spot files over 262,140 bytes),
walk back from the key2 seed to the iv1 and key1 seeds. The test is the first ChaCha chunk:
--known2-hex, or a zeros/text heuristic. - Stage-2 fallback for PDFs (
--stage2 auto, the default): if the zeros/text test finds nothing in a.pdf, the same window is searched again with two PDF tests. They use the bytes stage 1 has already made readable (the KCipher-2 chunks and the unencrypted tail):- xref: the PDF’s cross-reference tables or streams give the exact offset of every
N G objheader. Headers inside a ChaCha chunk are exact known plaintext, and ChaCha8 can produce the keystream at any offset directly, so each candidate costs one 64-byte block. The test is used only if every header that falls in readable bytes sits where the xref says. A candidate is accepted when at least 90% of the in-chunk headers decrypt. - deflate: a compressed page-content stream that starts in readable bytes and runs into the first ChaCha chunk. zlib is primed with the readable part, each candidate’s output continues it, and the result must be valid page-content syntax (numbers, names, strings, operators with the right operand count). A candidate is accepted only if the whole stream then inflates to its end with a valid Adler-32.
--stage2 heuristickeeps the old behaviour;--stage2 pdfskips the zeros/text test and runs only the PDF tests. Neither PDF test fits every PDF: a file with no objects inside the ChaCha chunks and a binary stream (an image or font) at the chunk boundary is still not solved, and the tool says so. Both tests are pure Python: in the real runs the xref test took about 2 minutes and the deflate test about 6 minutes for the default window. - xref: the PDF’s cross-reference tables or streams give the exact offset of every
python akira_decrypt.py recover payroll.sqlite.akira --uptime 86412.10:86412.20 --out payroll.sqlite # default --gap 3800:8000
python akira_decrypt.py recover disk.vmdk.akira --key2-range 4098288573700:4098288603700 --gap 92680851:92681151 \
--gap-iv1 105363088:105363388 --gap-key1 465178935:465179235 --known-hex 00000000000000000000000000000000
python akira_decrypt.py benchmark --window 0.01 --gap-span 2000
python akira_decrypt.py recover report.pdf.akira --qpc-offset <...> --mtime <...> --stage2 pdf --out report.pdf # PDF tests only
Finding the window from victim timestamps (measured, ../04-dynamic/05-timestamp-seed-anchors.md).
A key2 seed is the uptime in ns, (FILETIME − FILETIME of QPC 0) × 100, and an .akira file’s
last-write time is set by the footer write. On the 21 run-4 files, all 12 KB with a ~1 ms clock
tick, the mtime came +0.45 to +5.36 ms after key2 (median +1.39 ms). --qpc-offset builds the
window from that:
# offset = precise system time minus QPC, measured on the victim before any reboot
python akira_decrypt.py recover report.pdf.akira --qpc-offset 2026-10-11T14:01:30.1479792Z \
--mtime 134362316012048995 --out report.pdf # default --mtime-window 8:1 (ms)
- Same boot (live victim or memory image): the offset is exact to microseconds. The default −8/+1 ms window plus the default gap is 3.8·10⁸ pairs, about 42 min for the first file.
- Default 15.6 ms clock tick: add ~16 ms of BACK, because the mtime can lag the write by one tick.
- Large files: add their encryption time to BACK.
- After a reboot:
LastBootUpTimeand the event-12StartTimehold KeBootTime at only 0.5 s granularity; on the VM it was +352 ms off. The first file then needs a ~±0.6 s window. That single solve pins the offset for every other file of the same boot.
Ransom-note floor. akira_readme.txt is written as a folder’s processing starts. Pass its
creation time as --floor, and the key2 window is clipped to no earlier than 2 ms before it.
--floor-only searches just the slice a first −8/+1 ms pass did not cover.
Large seed gaps: --anchor iv2. The window bounds iv2 instead of key2, and key2 = iv2 − gap.
iv2 sat 0.8–1.8 ms before mtime on every run-5 file, even the one with a 26,718-tick gap. A
large-gap second pass therefore costs (iv2 window × gap span) instead of twice that:
recover X.akira --qpc-offset <...> --mtime <...> --anchor iv2 --mtime-window 2.5:0.1 --gap 8001:30000
That is 572 M pairs, ~21 min on 20 workers in run 5. Run it after a default pass misses.
Seeds only increase across the run, so a solved file shows the real seed-to-seed gap, and that
narrows --gap. It does not pin its neighbours tightly. Each file is an asio coroutine, so one
file’s keys can lie anywhere between the note and its own mtime (run 5).
Run 5 (real files, blind): 4 of 4. A PDF, a DOCX and two PNGs were recovered from mtime + live
QPC offset + type-derived known plaintext, all byte-identical to the originals
(../04-dynamic/06-run5-blind-real-files.md):
- three files took 28–31 min each with the defaults in pure Python, and 20–26 s on the numba engine;
- the fourth had a 26,718-tick seed gap and needed the
--anchor iv2second pass (21 min).
Native seed gap (measured, run 4). An in-process QPC hook logged all 84 seeds of 21 files with no
seed breakpoints (../04-dynamic/04-run4-native-seed-gap.md). Consecutive seeds are about 0.4 ms
apart:
- key2 → iv2: min 3,889, median 4,121, max 27,109 ticks; 18 of 21 under 5,100 and 20 of 21 under 8,000.
- The floor is one Yarrow-256 seeding on the VM’s CPU, so it scales with the victim’s CPU speed.
- The tail comes from two encryptor threads competing for CPU.
- A file’s four seeds span a median of 1.25 ms.
- The next file on the same thread starts a median of 1.65 ms after the previous iv2.
Hence the default --gap 3800:8000. If nothing is found, widen it toward 3800:30000.
Cost on this host (20 threads, pure Python, measured): about 150,000 KCipher-2 pair tests per second and about 20,000–40,000 Yarrow derivations per second.
| key2 uncertainty | Gap 3,800–5,200 (18/21) | Gap 3,800–8,000 (20/21, default) | Gap 3,800–28,000 (21/21) |
|---|---|---|---|
| ±0.5 ms | ~1.6 min | ~4.7 min | ~27 min |
| ±5 ms | ~16 min | ~47 min | ~4.5 h |
| ±50 ms | ~2.6 h | ~7.8 h | ~45 h |
| ±0.5 s | ~26 h | ~78 h | C/GPU port needed |
Checked on two native-run outputs: ±0.25 ms and the default gap window found the true pair among 21 million candidates in 70–85 s.
How it works

All four key sources end in the same four values. The tool then rebuilds the sample’s chunk
plan (regions → chunk_plan) and XORs each chunk with the matching keystream
(decrypt_stream). The matching encryption flow is in ../artifacts/diagrams/encryption-flow.png.
Demo on real sample output
../artifacts/decryptor-demo-2026-10-11.txt covers:
infoon the three run-3 files;decrypt --seedson each, with every output checked to be all zeros of the original size;- two negative controls: a key2 seed one tick off gives garbage, and a wrong gap window gives no hit;
recoverwith no keys: the Spot file in 0.4 s, and the Full file’s four seeds in 0.8 s.
recover prints key1 with its upper 16 bytes as zeros. ChaCha uses only key1[0:16], so the upper
half is never searched.
Validation
| Test | What it proves | Result |
|---|---|---|
selftest |
KCipher-2 matches RFC 7008 C.1; captured seeds reproduce a captured RSA blob; captured blob parses to the keys seen at the cipher inits; captured chunk 1 (KCipher-2) and chunk 2 (ChaCha8) of a real file decrypt; the fast search primitives agree with the reference code; Part stripe offsets match the live run | 8/8 pass |
test_real_outputs.py |
Three files encrypted by the live sample (Spot 0x3000 B, Full 0x30123 B with a partial last chunk, Part 3,000,000 B) decrypt byte-exact via both --seeds and --keys; recover finds all their seeds from a window using only 16 zero bytes of known plaintext, then decrypts byte-exact. Plus two native-run (run 4) files: decrypted with the hook-logged seeds, and recovered with the default --gap and from mtime + QPC offset (--qpc-offset). Plus the run-5 real PNG with the 26,718-tick gap, recovered with --anchor iv2 and checked against the original SHA-256 |
15/15 pass |
| Stress run | recover over 20,012,001 candidate pairs (8-byte filter) found the single true pair, no false positives, 69 s |
pass |
test_pdf_stage2.py |
The PDF stage-2 fallback on synthetic PDFs encrypted the sample’s way: a classic-xref PDF solved by the xref test (--stage2 pdf), a page-content PDF solved by the deflate test after the zeros/text test fails (default auto), and a binary-stream PDF where neither applies and the tool says so, still decrypting the KCipher-2 chunks |
3/3 pass |
Real PDFs (../artifacts/stage2-integrated-recover-2026-10-14.txt) |
The two analyst PDFs that the blind runs left partly decrypted (run 6, build 2), re-run with the default command line: run-6 PDF solved by the deflate test, build-2 PDF by the xref test, both SHA-256 equal to the hash sealed before encryption. The deflate test alone also solves the build-2 PDF (../../2026-10-13_988776358d0e_akira-build2/artifacts/stage2-deflate-test-crosscheck-2026-10-14.txt) |
2/2 hash-exact |
test_roundtrip.py |
RSA footer decryption, PEM/DER/PKCS#8 parsing, all modes incl. Part with percent < 50, .arika journal handling, CLI. Uses a locally generated RSA key, so this checks the tool’s own consistency only |
29/29 pass |
Not validated against real data: decryption through the operator’s private key, which is unavailable. The footer path is tested only with our own RSA key, following the byte layout observed in the sample.
Speed
recover: stage 1 tests (key2, iv2) seed pairs with KCipher-2.
- Engines (
--engine auto|numba|python, default auto):- pure Python: about 150k pairs/s on 20 workers;
- numba kernel
akira_fast.py: 40–45M pairs/s sustained, 97M/s in short bursts (benchmark).
- Safety: the kernel is a line-for-line port. At load it is checked against the Python reference on random keys and IVs, and the tool falls back to Python if any check disagrees. Every hit is confirmed with the full KCipher-2 before it is accepted.
- Run-5 re-run (
artifacts/run5-numba-rerun-2026-10-12.txt, all four files hash-exact):
| File | Pure Python | numba |
|---|---|---|
1.pdf |
1,875 s | 21 s |
| DOCX | 1,679 s | 26 s |
| second PNG | 1,802 s | 22 s |
first PNG, --anchor iv2 second pass |
1,240 s | 16 s |
Most of the remaining time is the Yarrow table (1,500 SHA-256 calls per seed, about 2k seeds/s per core). That table, not the pair test, now limits very wide windows.
decrypt: pure Python decrypts about 2 MB/s of encrypted data. Only the encrypted regions are
processed: the whole file in Full mode, at most 50% in Spot, and 40% in Part at the
default percent. A 10 GB VM disk in Part mode has 4 GB to process, about 35 minutes.