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:

  1. 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-hex or --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.
  2. 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.
  3. 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 obj header. 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 heuristic keeps the old behaviour; --stage2 pdf skips 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.

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)

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

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:

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

Decryption flow

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:

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.

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.