Breaking Akira's clock: recovering files without the ransom key
Malware Analysis Series  ·   ·  updated  ·  by shaddy43

Breaking Akira's Clock: Recovering Files Without the Ransom Key

An Akira-branded Windows encryptor builds every file key from the machine’s uptime. We take it apart function by function, show how a responder can turn that into decrypted files, prove it on real documents, and spell out exactly when it will not work.

Ransomware Decryptor Reverse Engineering Incident Response

📦 Decryptor, detections and the full case files are in the Source repository. Samples use password infected.

Key findings

1entropy source per keythe uptime counter, as decimal text
~0.4 msbetween a file's four seedsmedian 4,121 ticks, measured natively
~25 sper file to recovercompiled engine, 20-thread host
7 / 7in-scope files recoverable, per buildblind, frozen-rule score: 6 / 7
153database extensions decodedalways encrypted in full
0network, persistence or theft codeit encrypts and leaves

How this page is organised. Part I (sections 01–08) is the story: what the sample does, where its cryptography breaks, and how we got real files back. Part II (sections 09–18) is the full reverse engineering behind it: every function, structure, constant and measurement, with the addresses to check them yourself. Sections 19–21 hold the indicators, detections and downloads.

Part I · The story Part II · Technical analysis Reference
01 Sample 09 Static profile 19 IOCs
02 Behaviour 10 main() and thread pools 20 Detection
03 Cryptography 11 Command line and targeting 21 Downloads
04 The flaw 12 Pre-encryption actions  
05 Decryption 13 String obfuscation  
06 Proof 14 The per-file pipeline  
07 Limits 15 Cipher internals  
08 Responders 16 Windows API map  
  17 Measuring the seeds  
  18 Inside recover  

Part I · The story

01 · A self-contained C++ encryptor that calls itself Akira

The binary is a 64-bit Windows executable written in modern C++ (C++20 coroutines on the asio I/O library), with the Nettle crypto library compiled in. It brands itself Akira: .akira extension, akira_readme.txt notes and Akira onion addresses. This build has no command-and-control, no persistence and no data theft. It encrypts and leaves. The data theft its note claims is not done by this file.

Property Value
SHA-256 101bd45ab673fd6547386dff4339f1649e61b9f5c318260d365f766fbdc2d3c9
MD5 427eba9f6ff2cd312c72770f13e279c1
Type PE32+ x86-64, GUI subsystem
Compiled 2023-12-28 14:49 UTC
Toolchain MSVC 14.37, C++20, asio, Nettle
Ciphers ChaCha8 + KCipher-2, RSA-4096 key wrap

Every finding in this post comes from our own static analysis in Ghidra and controlled detonations under x64dbg in an isolated VM: six runs of this build and one of a second build. Each cryptographic primitive was re-implemented in Python and matched against data captured from the running sample.

02 · From launch to .akira in six moves

  1. Reads its targets from --encryption_path=, a share list (--share_file=), or, with neither, a sweep of the first five drive letters. -localonly skips network shares. A bare -p is silently ignored, which turns a targeted run into a whole-machine run.
  2. Opens an operator log named Log-DD-MM-YYYY-HH-MM-SS.txt in its working directory.
  3. Deletes Volume Shadow Copies by asking WMI to start a hidden powershell.exe -Command "Get-WmiObject Win32_Shadowcopy | Remove-WmiObject". The PowerShell process appears as a child of WmiPrvSE.exe, not of the ransomware.
  4. Frees locked files with the Restart Manager, force-closing whatever holds them while sparing a built-in list of 20 system processes.
  5. Drops akira_readme.txt in each folder it enters, then hands files to a pool of encryptor threads (two on our 2-CPU VM).
  6. Encrypts each file in place, appends a 512-byte RSA footer and renames it to name.akira. A temporary name.arika journal lets an interrupted file be resumed and is deleted afterwards.
Execution flow: five one-time set-up steps in main at 0x14004ca40, then a six-step pipeline per file in the encryptor threads at 0x1400ae840
The run sets up once in main, then every file goes through the same six-step coroutine. Section 10 walks the left column and section 14 the right, function by function.

03 · Strong ciphers, fed by a stopwatch

Each file gets four fresh 16-byte values, generated back to back: key1, iv1, key2 and iv2. Each value comes from its own new random generator, and every generator is seeded the same way.

Stage What Detail
Seed ⚠️ Uptime counter × 100 QueryPerformanceCounter at 10 MHz, as decimal text, e.g. 870401719600. The only input.
Generator Fresh Yarrow-256 Nettle’s Yarrow: SHA-256 pools, AES-256 output. New instance per value.
Keys First 16 bytes key1, iv1, key2, iv2 in turn, about 0.4 ms apart.
Ciphers KCipher-2 + ChaCha8 key2/iv2 drive KCipher-2; key1/iv1 drive an 8-round ChaCha with a 128-bit key.
Wrap RSA-4096 footer A 91-byte blob (mode, percent, size, all four values) sealed into 512 bytes.

Key generation, per file. Everything below the first row is sound cryptography. The first row decides everything.

Encryption flow: one uptime reading per value feeds a fresh Yarrow-256; the four values drive ChaCha8 and KCipher-2 and are sealed in a 91-byte blob under RSA-4096; the file pipeline opens, journals, picks a mode, encrypts, appends the footer and renames
Every per-file key traces back to one uptime reading. Left: key generation in FUN_140036740. Right: the file pipeline in FUN_1400ae840. Section 15 has the cipher-level detail.

Three modes, chosen by extension and size

The sample rarely encrypts a whole file. It reads the file in 0xFFFF-byte chunks. The first and last chunk of each encrypted region use KCipher-2, and the chunks in between use ChaCha8.

Akira on-disk layout after encryption, by mode SPOT · MOST FILES UNDER 2 MB · THE FIRST p% (DEFAULT 50%) encrypted untouched RSA FULL · 153 DATABASE EXTENSIONS (.SQLITE, .MDF, .ACCDB …) encrypted RSA PART · VM AND DISK IMAGES, OR ANYTHING OVER 2,000,000 BYTES · 4 STRIPES untouched RSA stripes start at 0 · 360,000 · 720,000 · 1,080,000 (3,000,000-byte file at 50%) encrypted left as plaintext 512-byte RSA footer
The file after encryption. Part mode is drawn for a 3,000,000-byte file at 50%: stripes start at 0, 360,000, 720,000 and 1,080,000, as observed live. Spot and Part leave large parts of every file readable.

04 · A 128-bit key with a few thousand possible values

The uptime counter ticks ten million times a second. If you know when a file was encrypted to within one second, there are only 107 possible seeds for each key, and every seed gives exactly one key. If you know it to within a few milliseconds, there are tens of thousands.

The four values of a file are also generated in a fixed order and very close together. On our test VM, consecutive seeds were about 0.4 ms apart (median 4,121 ticks between key2 and iv2; 20 of 21 files under 8,000). So once you have one value, the next is never far away. Section 17 shows how we measured this without disturbing the sample.

Why the RSA layer does not help the attacker here

The RSA-4096 footer protects the keys from anyone without the operator’s private key. But we never need to open it. We rebuild the keys from the same uptime values the sample used, so the strong outer layer is simply bypassed.

05 · Decryption in plain English

Guess the uptime at the moment a file was encrypted and you can rebuild that file’s keys. Many files only need key2 and iv2: at the default 50%, a Spot-mode file under about 250 KB is encrypted in at most two chunks, and both use KCipher-2.

  1. Narrow down when the file was encrypted. On the still-running victim machine, read its clock and its uptime counter at the same moment to learn how one converts to the other. The encrypted file’s “last modified” time then gives its uptime at encryption to within a few milliseconds.
  2. Guess the key. Try every uptime value in that window, about 90,000 candidates for a 9 ms window. Each candidate rebuilds a key exactly as the ransomware did.
  3. Guess the companion value. It was made about 0.4 ms after the key, so for each key candidate try the next few thousand uptime values. That is roughly 380 million pairs in total.
  4. Check each guess. Every PDF starts with %PDF-1., every PNG with the same 16 bytes. Decrypt the first few bytes with each pair; the pair that produces the right header is the right one.
  5. Decrypt the file. Replay the sample’s own chunk plan with the recovered keystream, strip the 512-byte footer, and the original is back byte for byte.
Decryption flow: four key sources (operator private key, raw keys, seeds, or recover with no key material) converge on the same four per-file values, then mode and size, the sample's chunk plan, keystream replay and the restored file
Four ways in, one replay of the sample's own chunk plan. Only recover needs no key material at all; section 18 explains its two search stages and the PDF fallback.

Steps 2–4 are the slow part. In pure Python they took about 30 minutes per file on our 20-thread analysis host. With the optional compiled engine (pip install numpy numba) the same search takes about 20 seconds. In the decryptor this is one command:

python akira_decrypt.py recover report.pdf.akira \
    --qpc-offset 134362707471549158 \
    --mtime 134362716175578720 \
    --known-hex 255044462d312e \
    --out report.pdf

--qpc-offset is the clock-to-uptime offset from step 1 and --mtime the file’s last-write time, both as Windows FILETIME values. --known-hex here is %PDF-1.. The values shown are from our run-5 PDF.

If the first pass misses. Occasionally the companion value comes much later than usual (section 06). Run a second pass anchored on the companion instead. On all eighteen files whose keys we have found so far, across both builds, it landed 0.5–1.9 ms before the file’s timestamp, so you can afford to try much larger gaps:

python akira_decrypt.py recover photo.png.akira \
    --qpc-offset 134362707471549158 \
    --mtime 134362716175680085 \
    --anchor iv2 --mtime-window 2.5:0.1 --gap 8001:30000 \
    --out photo.png

Large PDFs need nothing extra. When the generic ChaCha8 check finds nothing in a .pdf, recover retries the same window with two PDF-specific checks (section 18). That took 2–6 minutes per file in pure Python.

06 · Real documents recovered blind, in three runs across two builds

Run 5. For the first test we reverted the VM, placed four real files in a folder, and let the sample encrypt them with no hooks and no breakpoints in its crypto path. Two safety gates stayed in place: the shadow-copy deletion was skipped, and the target folder was confirmed before anything ran. Afterwards we used only what a responder would collect:

File Size Known bytes Search time (pure Python) Re-run, compiled engine Result
1.pdf 97,577 B 7 (%PDF-1.) 31 min 21 s ✅ SHA-256 identical
QC-STC-T-45-26_Sample_IR_Deliverables_v1.0.docx 42,333 B 28 (ZIP header) 28 min 26 s ✅ SHA-256 identical
Screenshot 2026-10-05 102304.png 117,188 B 16 (PNG header) 30 min 22 s ✅ SHA-256 identical
Screenshot 2026-09-29 125745.png 47,803 B 16 (PNG header) 81 min missed, then 21 min (second pass) 13 s missed, then 16 s ✅ SHA-256 identical
Run 5 timeline in milliseconds after the ransom note was created For all four files the key2 seed comes 1.2 to 3.6 ms before the file's last-write time, and the iv2 seed 0.8 to 1.8 ms before it. Three files have iv2 about 0.4 ms after key2; the first PNG's iv2 came 2.67 ms after its key2. −2 0 4 8 12 16 ms ransom note created process ends +15.24 PDF key2 +2.88 → mtime +4.11 DOCX key2 +6.83 → mtime +9.05 PNG (2nd) key2 +10.61 → mtime +12.15 PNG (1st) 2.67 ms gap key2 +10.65 → mtime +14.25 key2 seed (recovered) iv2 seed (recovered) file last-write time outlier seed gap
Run 5 to scale, in milliseconds after the ransom note was written. All four files were encrypted within about 15 ms. On every file, iv2 was generated 0.8–1.8 ms before the last-write time, which is why the timestamp is such a good anchor. The first PNG is the outlier: its key2 came at nearly the same moment as the second PNG's, but its iv2 came 2.67 ms later instead of the usual 0.4 ms.

The fourth file: a timing outlier. The first pass searched the whole span from 2 ms before the ransom note to just after the file’s own timestamp, allowing any gap up to 8,000 ticks between the two seeds, and found nothing in 81 minutes. Its seeds turned out to be 26,718 ticks (2.67 ms) apart, almost seven times the usual 0.4 ms. That fits the one outlier we measured earlier (27,109 ticks), on a VM that had been up for only 15 minutes. The second pass searched from the companion value, which still sat 0.93 ms before the timestamp, and allowed gaps from 8,001 to 30,000 ticks. It found the keys in 21 minutes, and the decrypted screenshot was SHA-256 identical to the original.

Run 6: a fully independent re-test

Run 5 had a weakness: we designed the second pass while solving its fourth file. So we ran the whole test again, with three changes:

File Size Known bytes Pass Search time key2→iv2 gap Result
20358696.jpg 24,287 B 11 (JFIF header) 1 14 s 4,173 ✅ SHA-256 identical
photo_2026-09-23_15-02-25.jpg 172,871 B 11 (JFIF header) 1 26 s 4,072 ✅ SHA-256 identical
photo_2026-09-24_12-44-15.jpg 214,866 B 11 (JFIF header) 1 26 s 4,139 ✅ SHA-256 identical
QC-STC-T-45-26_Sample_IR_Deliverables_v1.0.docx 42,333 B 28 (ZIP header) 1 26 s 3,933 ✅ SHA-256 identical
Screenshot 2026-09-10 152951.png 26,953 B 16 (PNG header) 1 26 s 3,993 ✅ SHA-256 identical
Screenshot 2026-09-29 125745.png 47,803 B 16 (PNG header) 1 24 s 5,570 ✅ SHA-256 identical
Autopsy_DFIR_Validation_Playbook_Extended.pdf 481,630 B 7 (%PDF-1.) 1 22 s, then 2 min 4,141 ⚠️ Keys found, middle not decrypted (recovered afterwards)
Screen Recording 2026-09-24 104237.mp4 6,799,834 B none – – – Out of scope

What run 6 shows.

The PDF: a limitation we predicted

The PDF is over 262 KB, so the middle of its encrypted half is ChaCha8, not KCipher-2. We found its KCipher-2 keys in 22 seconds, so the first and last 64 KB of the encrypted half decrypted correctly. The ChaCha8 search tested 24 million candidate pairs. It accepts a key only if the output looks like zeros or plain text, and nothing passed. We flagged this risk in writing before the run. The MP4 was out of scope because no fixed header rule exists for it yet.

A second build, same flaw

A different Akira-branded build, SHA-256 988776358d0e45a4907dc1f4906a916f1b3595a31fa44d8e04e563a32557eb42, was the next test. It is the same C++ codebase recompiled with new string-obfuscation constants, a new victim chat code and its own operator RSA key. We read its code before running it:

We then repeated run 6 exactly:

Recovery used build 1’s decryptor byte-for-byte unchanged.

File Size Known bytes Pass Search time key2→iv2 gap Result
20358696.jpg 24,287 B 11 (JFIF header) 1 15 s 4,900 ✅ SHA-256 identical
photo_2026-09-23_15-02-25.jpg 172,871 B 11 (JFIF header) 1 26 s 5,380 ✅ SHA-256 identical
photo_2026-09-24_12-44-15.jpg 214,866 B 11 (JFIF header) 1 30 s 3,896 ✅ SHA-256 identical
QC-STC-T-45-26_Sample_IR_Deliverables_v1.0.docx 42,333 B 28 (ZIP header) 1 26 s 4,225 ✅ SHA-256 identical
Screenshot 2026-09-10 152951.png 26,953 B 16 (PNG header) 1 28 s 4,306 ✅ SHA-256 identical
Screenshot 2026-09-29 125745.png 47,803 B 16 (PNG header) 1 22 s 4,013 ✅ SHA-256 identical
Detection Lab Automated BAS-to-Splunk Detection Engineering Loop.pdf 595,456 B 7 (%PDF-1.) 1 22 s, then 2 min 4,086 ⚠️ Keys found, middle not decrypted (recovered afterwards)
rule.yml 981 B none – – – Out of scope
AnyDesk.exe 8,375,224 B – – – – Not encrypted (.exe excluded)

The result matches build 1.

Reading the code showed the key generation was the same. This run confirmed what reading code cannot: the chunk layout, the cipher details and the timing all line up.

The two PDFs, recovered afterwards

After both runs we went back to the two PDFs to settle the open question: was the right ChaCha8 key outside the search window, or did the check fail to recognise it? It was the check. Both PDFs’ seeds sat inside the default window, with ordinary gaps of 3,910–4,287 ticks. In the run-6 PDF the whole ChaCha8 region (bytes 65,535–196,605) is one 291 KB compressed page-content stream, and the zeros/text check accepts 0 of 971 genuine 64-byte windows of it.

Once the KCipher-2 chunks are decrypted, most of a PDF’s structure is readable again, which allows two PDF-specific tests (detail in section 18):

PDF Test that solved it Pairs searched Candidates passing Standalone Default recover command Result
Run 6 (build 1), 481,630 B continuing a page-content stream’s deflate 23,970,816 1 2,300 s 8 min 16 s ✅ SHA-256 = sealed hash
Build 2, 595,456 B 968 object headers from the xref 24,373,969 1 89 s 4 min 52 s ✅ SHA-256 = sealed hash

Both tests now run automatically inside recover. With them, 7 of 7 in-scope files on each build are recoverable. We still report the blind runs as 6 of 7, because the tool frozen for those runs did not include the fallback, and changing a score after the fact would undo the point of freezing the rules.

Earlier validation

Before the blind test, the decryptor reproduced byte-exact output for real .akira files from all three modes (Spot, Full and a 3 MB Part file). Keys rebuilt from captured seeds matched the keys inside the sample’s own RSA plaintext blob, and a seed off by a single tick produced garbage. An earlier blind run on another file found its seeds from the timestamp alone in 28 minutes. Current test suites: 8/8 built-in checks, 15/15 on real sample output, 29/29 round trips, 3/3 synthetic PDF stage-2 cases.

07 · When this recovery will and will not work

This is a real weakness, but it is a timing attack. Everything depends on knowing when each file was encrypted. Read these before promising anyone their data back.

Critical Do not reboot the victim

The clock-to-uptime offset must come from the same boot the ransomware ran in: a live, un-rebooted machine, or a memory image taken before shutdown (it holds both clocks). After a reboot, Windows only records the boot time to about half a second; on our VM it was 352 ms off. The first file then needs a search window around a second wide, which takes days with our Python tool.

Critical File timestamps must survive

The .akira file’s last-write time is the anchor. Copy files with their timestamps preserved, or record them before copying. Restoring from backup, re-saving, or timestomping destroys the anchor. The search then falls back to the run’s time span, which is far more expensive.

Required Known bytes at the start of the file

The check in step 4 needs at least 7 bytes of plaintext at offset 0. Most document, image, archive and database formats have a fixed header, but plain text and some proprietary formats do not. Video formats such as MP4 have no rule in the tool yet.

Partial Large files: ChaCha8 middle

Spot files over about 262 KB, database files (Full mode) and files over 2 MB (Part mode) also use ChaCha8, which needs key1 and iv1. The generic check accepts a ChaCha8 key only if the output looks like zeros or text. PDFs now have a fallback that needs either objects inside the ChaCha8 chunks or a page-content stream crossing into them; a PDF with neither (for example a binary image stream at the chunk boundary) still fails. Other large compressed files, such as Office documents, archives and video, can come back partly decrypted.

Cost Seconds per file, if the window is tight

Each file needs its own search: about 20 seconds on a 20-thread host with the compiled engine, or 30 minutes in pure Python. The cost grows with the time window. After a reboot (a window of about a second), building the candidate keys alone takes tens of minutes per file. A GPU port would help there, and we have not built one.

Not guaranteed Timing outliers

One of our four run-5 files had its two seeds 2.67 ms apart instead of about 0.4 ms, so the standard search missed it. A second pass anchored on the companion value, with gaps up to 30,000 ticks, recovered it in 21 minutes. In run 6, none of the seven files was an outlier. Busy or freshly booted machines will produce more such files, and an even longer stall would fall outside that second pass too.

Scope Measured on one setup

Seed spacing and timestamp behaviour were measured on one 2-CPU VM running with a 1 ms system timer. A slower or faster CPU shifts the spacing. Hosts at the default 15.6 ms timer tick, and very large files, need wider windows; neither was tested.

Scope Two builds tested

The method is validated on two builds of the same C++ codebase: 101bd45a… and 98877635…. Other Akira-branded encryptors, such as builds from other codebases or later rewrites, may generate keys differently. Check the key generation in the code before relying on this method for another sample.

Scope Operator key path untested

The decryptor can also open the RSA footer with the operator’s private key, should it ever leak. That path is tested only with our own RSA key pair, since the real key is not available.

08 · Preserve the clock before anything else

  1. Do not reboot or power off the affected machine. Isolate it from the network instead.
  2. Capture memory if you can, then record a clock pair with the snippet below. Run it a few times and keep the median.
  3. Collect, with timestamps intact: the .akira files, every akira_readme.txt (its creation time marks when a folder started), the operator’s Log-*.txt, and the System event log.
  4. Work on copies, smallest and most important files first.
Add-Type @'
using System; using System.Runtime.InteropServices;
public static class Clk {
  [DllImport("kernel32.dll")] public static extern bool QueryPerformanceCounter(out long c);
  [DllImport("kernel32.dll")] public static extern bool QueryPerformanceFrequency(out long f);
  [DllImport("kernel32.dll")] public static extern void GetSystemTimePreciseAsFileTime(out long ft);
}
'@
$f = 0L; $q0 = 0L; $ft = 0L; $q1 = 0L
[void][Clk]::QueryPerformanceFrequency([ref]$f)
[void][Clk]::QueryPerformanceCounter([ref]$q0)
[Clk]::GetSystemTimePreciseAsFileTime([ref]$ft)
[void][Clk]::QueryPerformanceCounter([ref]$q1)
$q = [long](($q0 + $q1) / 2)
"freq=$f  qpc=$q  filetime=$ft  offset=$($ft - $q)"

With a 10 MHz counter (freq=10000000, the usual value) the offset is simply FILETIME minus the counter, and it goes straight into --qpc-offset. On other frequencies, scale the counter to 100 ns units first. The full collector, collect_clock_and_times.ps1, also records file timestamps, ransom notes and the operator log.


Part II · Technical analysis

Everything below is from our own work on build 1 (101bd45a…) unless marked otherwise. Addresses are static, against Ghidra’s image base 0x140000000; on the VM the image loaded at a different ASLR base on each boot, so live addresses were rebased. Ghidra function names (FUN_…) are kept so you can follow along in your own database.

09 · Static profile

Property Value
Format PE32+ x64, Windows GUI subsystem, 7 sections, ASLR / relocatable
PE timestamp 0x658D8B15 = 2023-12-28 14:49:57 UTC
Toolchain MSVC, linker 14.37 (Visual Studio 2022 17.7), C++20 coroutines, RTTI present
Debug info POGO and ILTCG entries only; no PDB path
Statically linked asio (IOCP async file I/O), Nettle (Yarrow-256, RSA), mini-GMP (bignums), an spdlog-style logger, an argh-style CLI parser
Packing None
Obfuscation Three string groups only (the shadow-copy command and two extension lists), stack-built and decoded per string with an affine map mod 127 (section 13)
Anti-analysis None found. IsDebuggerPresent is reached only on CRT fail-fast paths, and the sample ran normally under x64dbg
Imports 49 behaviour-relevant imports, all called directly through the import table; nothing is resolved at runtime except by CRT and STL helpers
Network WSAStartup is asio’s winsock initialiser; no socket is ever created

Capabilities, all confirmed in code and most also observed live:

10 · main() and the thread pools

The CRT entry at 0x1400855b8 reaches main at 0x14004ca40 (FUN_14004ca40). main does all one-time set-up, then hands work to three thread pools. Everything after the pools start is C++20 coroutines on an asio I/O completion port.

  1. Logging (FUN_14004c6f0). An spdlog-style file_logger writing Log-%d-%m-%Y-%H-%M-%S.txt in the working directory. The start time is taken with QueryPerformanceCounter at 0x14004d505.
  2. Command line (FUN_140053d70). GetCommandLineW and CommandLineToArgvW, then an argh-style parser (section 11).
  3. Targets, from exactly one source in priority order: the explicit path, then the share file (FUN_140041fc0), then the drive sweep (FUN_1400766e0). In -l mode the run stops here, after logging the drive list.
  4. Pre-encryption steps, run once: skip-list update (FUN_14006ecf0), protected-PID snapshot (FUN_140070c30), shadow-copy deletion (FUN_140071d80 → FUN_140071620). Section 12.
  5. Crypto init. The RSA-4096 public key is imported from DER at 0x1400ee010 (FUN_14007ba90 / FUN_140081ac0). The RSA context gets its own Yarrow instance for PKCS#1 padding (FUN_14007bbe0), seeded the same QPC-only way as the file keys.
  6. Thread pools (FUN_1400737d0), sized from GetSystemInfo.
  7. Root-folder queue (FUN_140073950). Each root is queued here; r8 holds the root as a std::wstring. This was our dynamic safety gate: a breakpoint here showed exactly which root would be encrypted, before any file was touched.
  8. Folder parsers (FUN_1400b5fb0, FUN_1400b81d0) apply the exclusions, drop akira_readme.txt (FUN_140042420), set the force-Full and force-Part flags and enqueue each file with FUN_140074480(queue, path, fullFlag, partFlag, ctx, percent, …). The two flags land in task fields +0x49 and +0x4a.
  9. Per-file coroutine (FUN_1400ae840), section 14.
  10. main waits for the pools, logs the elapsed time (end QPC at 0x14004e06f) and exits. No self-deletion and no persistence.

Thread-pool sizing. With N logical CPUs, N is first doubled when it is below 5 (and treated as 2 when it is 1). Then 30% of N become root-folder parsers, 10% (at least one) folder parsers, and the rest encryptors. A 2-vCPU VM therefore gets 1 / 1 / 2, which matches the two encryptor threads we saw interleave in run 4. The log line Number of threads to encrypt = records the result.

The final log line is the run time in milliseconds. In run 4 it read 423038 ms, which is exactly (main-end QPC − main-start QPC) / 10⁴. That is how we know the sample’s own stopwatch is the same counter that seeds its keys.

11 · Command line and targeting

The parser is argh-style with no registered parameters, so a value must be attached with =. -p C:\x (with a space) is read as a bare flag -p followed by a stray positional argument. The path comes out empty and the sample falls back to sweeping every drive. We saw this live in run 1, which escaped its folder and had to be stopped.

Argument Effect Default
--encryption_path=<path> / -p=<path> Encrypt only this folder tree none
--share_file=<file> / -s=<file> Text file, one UNC path or drive per line; replaces the drive sweep none
--encryption_percent=<n> / -n=<n> Percent used by Spot and Part modes 50
-localonly Skip anything classified as a network disk or network path off
-l List drives to the log and exit without encrypting off

Target classification (FUN_140042c50), per root:

The drive sweep only reaches five drives. FUN_1400766e0 walks the buffer from GetLogicalDriveStringsW and filters with GetDriveTypeW, skipping CD-ROM and no-root-dir volumes. Its loop is compiled with a fixed bound, cmp rsi, 0x2C, that ends the walk after the fifth drive letter. A sixth or later volume (H: when C:–G: exist) is never encrypted in sweep mode. We confirmed this in the disassembly only, not in a live run.

Exclusions are exact-match lookups in std::set containers built by static initializers:

Set Members
Folder names skipped (0x1400f5e18) tmp, winnt, temp, thumb, $Recycle.Bin, $RECYCLE.BIN, System Volume Information, Boot, Windows, Trend Micro, ProgramData
Extensions skipped (0x1400f5e08) .exe, .dll, .lnk, .sys, .msi, plus .akira and .arika added at runtime
Files skipped akira_readme.txt; any file whose <name>.arika journal already exists
Protected processes (0x1400f5e28) spoolsv.exe, fontdrvhost.exe, cmd.exe, explorer.exe, sihost.exe, SearchUI.exe, lsass.exe, dwm.exe, LogonUI.exe, winlogon.exe, services.exe, csrss.exe, smss.exe, System Idle Process, Secure System, conhost.exe, System, wininit.exe, Registry, Memory Compression

The set initializers are FUN_1400018a0 (folders) and FUN_140001ac0 (extensions).

12 · Pre-encryption actions

Three steps run once, in this order, before any file is touched. They are skipped in -l mode. Nothing else runs beforehand: no service stops, no event-log clearing, no firewall or boot-configuration changes.

1 · Skip-list update (FUN_14006ecf0). Adds .akira and .arika to the extension skip set, so encrypted and in-progress files are never encrypted twice.

2 · Protected-PID snapshot (FUN_140070c30). WTSEnumerateProcessesW (0x140070c79) lists running processes, and the PIDs whose names match the 20-name protected set are stored. These PIDs are exempt from the Restart Manager kill below.

3 · Shadow-copy deletion (FUN_140071d80 → FUN_140071620).

bstrString    = SysAllocString(L"ROOT\\CIMV2");
bstrString_01 = SysAllocString(L"Win32_Process");
bstrString_02 = SysAllocString(L"Win32_ProcessStartup");
...
local_90.n2.vt = 0x13;  local_90._8_4_ = 0;            /* VT_UI4 0  */
(**(code **)(*(longlong *)local_100 + 0x28))(local_100.Lo64, L"CreateFlags", 0, &local_90);
local_78.n2.vt = 0x11;  local_78._8_1_ = 0;            /* VT_UI1 0 = SW_HIDE */
(**(code **)(*(longlong *)local_100 + 0x28))(local_100.Lo64, L"ShowWindow", 0, &local_78);

Per file, only when needed: Restart Manager (FUN_140070e30). The per-file coroutine calls this only when CreateFileW fails with error 0x20 (ERROR_SHARING_VIOLATION) or 0x21 (ERROR_LOCK_VIOLATION):

  1. RmStartSession, RmRegisterResources on the file, then RmGetList (twice: size, then data) for the processes holding it.
  2. If any holder is the ransomware itself or a protected PID, RmEndSession and give up on that file.
  3. Otherwise RmShutdown(session, RmForceShutdown) at 0x140071024, which kills the holders (database engines, Office, backup agents), and the open is retried. A file that still fails is logged and skipped.

13 · String obfuscation: an affine map mod 127

Only the three most signature-worthy string groups are hidden. Each string (or table) is decoded byte by byte with its own constants, in the form ((±(b − K))·M) mod 0x7F or (a·b + c) mod 127:

Group Where How
Shadow-copy command FUN_140071d80 76 stack bytes, ((0x39 − b)·0x0B) mod 0x7F
VM / disk-image extensions (20) FUN_140034500 → set 0x1400f5e38 Wide stack strings, one key per string
Database extensions (153) FUN_140003680 → set 0x1400f5e60 153 tables in .rdata from about 0x1400d3c00, one affine key per table

The database list took some work. Ghidra’s decompiler timed out on FUN_140003680 (a huge static initializer), and inline Ghidra scripts were disabled in our setup. So we exported the function’s full disassembly (1.65 MB) and a 40,000-byte .rdata dump, and parsed both on the host. Each table’s key was solved by UTF-16 known plaintext (every entry starts with . and ends with a NUL), then checked against the whole table.

Everything else is plaintext: the log strings, the note, the CLI option names and .akira. Those make good detection anchors (section 20).

The 153 database extensions (always Full mode)
.4dd .4dl .accdb .accdc .accde .accdr .accdt .accft .adb .ade .adf .adp .arc .ora .alf .ask .btr .bdf
.cat .cdb .ckp .cma .cpd .dacpac .dad .dadiagrams .daschema .db .db-shm .db-wal .db3 .dbc .dbf .dbs
.dbt .dbv .dbx .dcb .dct .dcx .ddl .dlis .dp1 .dqy .dsk .dsn .dtsx .dxl .eco .ecx .edb .epim .exb .fcd
.fdb .fic .fmp .fmp12 .fmpsl .fol .fp3 .fp4 .fp5 .fp7 .fpt .frm .gdb .grdb .gwi .hdb .his .ib .idb .ihx
.itdb .itw .jet .jtx .kdb .kexi .kexic .kexis .lgc .lwx .maf .maq .mar .mas .mav .mdb .mdf .mpd .mrg
.mud .mwb .myd .ndf .nnt .nrmlib .ns2 .ns3 .ns4 .nsf .nv .nv2 .nwdb .nyf .odb .oqy .orx .owc .p96 .p97
.pan .pdb .pdm .pnz .qry .qvd .rbf .rctd .rod .rodx .rpd .rsd .sas7bdat .sbf .scx .sdb .sdc .sdf .sis
.spq .sql .sqlite .sqlite3 .sqlitedb .te .temx .tmd .tps .trc .trm .udb .udl .usr .v12 .vis .vpd .vvv
.wdb .wmdb .wrk
The 20 VM and disk-image extensions (always Part mode)
.vdi .vhd .vmdk .pvm .vmem .vmsn .vmsd .nvram .vmx .raw .qcow2 .subvol .bin .vsv .avhd .vmrs .vhdx
.avdx .vmcx .iso

14 · The per-file pipeline

Each queued file runs through one coroutine, FUN_1400ae840. We confirmed every step live. The excerpts are from Ghidra’s decompile of that function (artifacts/ghidra/FUN_1400ae840_per_file_coroutine.decompile.c in the case folder), lightly cleaned up for reading: casts dropped, NULL and INVALID_HANDLE_VALUE named, comments added.

Open. Read-only is cleared first (attrs ^ 1), then the file is opened for read, write and delete (0xC0010000), OPEN_EXISTING, overlapped (0x40000080). On a sharing or lock violation it calls the Restart Manager routine and tries again:

DVar18 = GetFileAttributesW(pWVar36);
if ((DVar18 != 0xffffffff) && ((DVar18 & 1) != 0)) {
  SetFileAttributesW(pWVar36, DVar18 ^ 1);              /* drop FILE_ATTRIBUTE_READONLY */
}
hFile = CreateFileW(pWVar36, 0xc0010000, share, NULL, 3, 0x40000080, NULL);
DVar18 = GetLastError();
if (hFile == INVALID_HANDLE_VALUE) {
  if ((cVar14 == '\0') && (DVar18 - 0x20 < 2)) {         /* 0x20 / 0x21: file is locked */
    uVar21 = FUN_140070e30(pWVar32);                     /* Restart Manager force shutdown */
    if ((char)uVar21 != '\0') {
      hFile = CreateFileW(pWVar32, 0xc0000000, 3, NULL, 3, 0x40000080, NULL);

Choose the mode. The two flags set by the folder parser decide first, then the size. Right after this, the coroutine reads the clock (FUN_140078dd4) and calls the key generator FUN_140036740:

if (*(char *)(task + 0x49) == '\0') {                       /* not a database extension */
  if ((*(char *)(task + 0x4a) != '\0') || (2000000 < size)) {
    mode = 2;                                               /* Part: VM extension or > 2,000,000 B */
  } else {
    mode = 1;                                               /* Spot: the first percent% */
  }
} else {
  mode = 0;                                                 /* Full: database extension */
}
...
FUN_140078dd4();                                            /* QPC read: per-file start */
uVar22 = FUN_140036740(ctx, keys);                          /* four Yarrow draws + RSA wrap */

(Here the task fields are also renamed: task + 0x49 / + 0x4a are the force-Full / force-Part flags from the folder parser, size is param_1[0xb8].)

The three modes in numbers. Each mode encrypts one or more regions in place, in 0xFFFF-byte chunks. Within every region the first and last chunk use KCipher-2 and the chunks between use ChaCha8.

Mode Loop Region(s) encrypted (S = size, p = percent) Observed live
0 Full FUN_1400b23a0 [0, S) zero_db.sqlite (0x30000 B) → mode byte 0
1 Spot FUN_1400b3580 [0, p·S/100) zero_small.txt, other folder files → mode byte 1
2 Part, p ≥ 50 FUN_1400b4780 4 stripes of B = p·S/500 bytes, starting every (S − 4B)/5 bytes zero_big.bin (3,000,000 B): reads at 0, 360,000, 720,000, 1,080,000
2 Part, p < 50 FUN_1400b4780 2 stripes of B = p·S/300 bytes, starting every (S − 2B)/3 bytes not exercised

At the default p = 50, Part mode covers only 40% of the file in four 10% stripes; 60% stays plaintext. Spot mode on a small document leaves its second half untouched. Those plaintext regions are what made the DOCX recoverable in runs 5 and 6: its ZIP central directory sits at the end of the file, unencrypted, and it tells us exactly what the first local header looked like.

Journal. Before encrypting, FUN_1400adb00 creates <name.ext>.arika with CreateFileW(GENERIC_READ|GENERIC_WRITE|DELETE, share R/W, CREATE_ALWAYS, FILE_FLAG_OVERLAPPED) at 0x1400adc3f and writes a 532-byte header. Before and after each chunk, bytes 0–8 are overwritten with a progress record.

Encrypt. Reads and writes go through asio’s overlapped ReadFile (0x140040993, FUN_1400407e0) and WriteFile (0x14004075d, FUN_1400405b0) on an I/O completion port. Each chunk is XORed with its keystream and written back at the same offset.

Seal and rename. FUN_1400b5a20 appends the 512-byte RSA footer at offset = original size. Then the open handle is renamed and the journal deleted:

BVar17 = SetFileInformationByHandle((HANDLE)param_1[0x1a], FileRenameInfo, pvVar37, (DWORD)local_ab8);
if (BVar17 == 0) {
  DVar18 = GetLastError();
  ... "file rename failed. System error:" ...
}
...
if (param_1[0xe1] != 0) {
  pWVar32 = (LPCWSTR)FUN_14003ac10(param_1 + 0xdf);         /* <name.ext>.arika */
  DeleteFileW(pWVar32);
}

On-disk structures.

Structure Offset Size Content
.akira footer S (original size) 512 RSA-4096 ciphertext of the 91-byte blob, 32-bit words byte-swapped
.arika header 0 1 0xFF initially; then 0 (chunk read and encrypted) or 1 (chunk written)
.arika header 1 8 Progress offset (end of the last processed chunk)
.arika header 9 11 0x01, mode, percent, original size
.arika header 20 512 The same RSA blob as the footer (532 bytes in total)

An encrypted file is therefore always exactly 512 bytes larger than the original, and the original size is recoverable from the file length alone. Because the journal carries the RSA blob and the progress mark, a file caught mid-encryption by a crash or a kill stays decryptable by the operator, and our decryptor can handle it too (--journal). The folder parser skips any file whose .arika already exists, so a restarted run does not encrypt a file twice.

Other artifacts.

The ransom note, verbatim
Hi friends,

Whatever who you are and what your title is if you're reading this it means the internal infrastructure of your company is fully or partially dead, all your backups - virtual, physical - everything that we managed to reach - are completely removed. Moreover, we have taken a great amount of your corporate data prior to encryption.

Well, for now let's keep all the tears and resentment to ourselves and try to build a constructive dialogue. We're fully aware of what damage we caused by locking your internal sources. At the moment, you have to know:

1. Dealing with us you will save A LOT due to we are not interested in ruining your financially. We will study in depth your finance, bank & income statements, your savings, investments etc. and present our reasonable demand to you. If you have an active cyber insurance, let us know and we will guide you how to properly use it. Also, dragging out the negotiation process will lead to failing of a deal.
2. Paying us you save your TIME, MONEY, EFFORTS and be back on track within 24 hours approximately. Our decryptor works properly on any files or systems, so you will be able to check it by requesting a test decryption service from the beginning of our conversation. If you decide to recover on your own, keep in mind that you can permanently lose access to some files or accidently corrupt them - in this case we won't be able to help.
3. The security report or the exclusive first-hand information that you will receive upon reaching an agreement is of a great value, since NO full audit of your network will show you the vulnerabilities that we've managed to detect and used in order to get into, identify backup solutions and upload your data.
4. As for your data, if we fail to agree, we will try to sell personal information/trade secrets/databases/source codes - generally speaking, everything that has a value on the darkmarket - to multiple threat actors at ones. Then all of this will be published in our blog - https://akiral2iz6a7qgd3ayp3l6yub7xx2uep76idk3u2kollpj5z3z636bad.onion.
5. We're more than negotiable and will definitely find the way to settle this quickly and reach an agreement which will satisfy both of us.

If you're indeed interested in our assistance and the services we provide you can reach out to us following simple instructions:

1. Install TOR Browser to get access to our chat room - https://www.torproject.org/download/.
2. Paste this link - https://akiralkzxzq2dsrzsrvbr2xgbbu2wgsmxryd4csgfameg52n7efvr2id.onion.
3. Use this code - 6188-EA-ABYW-YGRG - to log into our chat.

Keep in mind that the faster you will get in touch, the less damage we cause.

15 · Cipher internals

Every primitive below was re-implemented in Python and matched against data captured live from the running sample. Those re-implementations became the decryptor.

Component Implementation Where How we validated it
RNG Nettle Yarrow-256: two SHA-256 pools, an AES-256 counter, 1,500 reseed iterations. A new instance per value, seeded only with QPC × 100 as a decimal ASCII string, e.g. "867151895800" FUN_14007b1f0, FUN_1400822c0 Regenerated all 80 key bytes in a captured RSA blob from its four seeds (run 2), and again for all three run-3 files
Per-file secrets key1 32 B, iv1 16 B, key2 16 B, iv2 16 B, in that order FUN_140036740 Seen at both cipher inits
Cipher A ChaCha, key setup called with kbits = 0x40, which takes the 16-byte branch: constant “expand 16-byte k”, key1[0:16] used twice, nonce iv1[0:8], 64-bit counter from 0. 8 rounds (4 double-rounds). Partial 64-byte blocks are discarded at each 0xFFFF chunk FUN_14007b0b0 (init), FUN_14007c570 (key setup), FUN_14007beb0 (block) 128 B of live keystream matched only the 128-bit-key, 8-round variant; 12 and 20 rounds and the 256-bit key failed. Live state showed counter 0x400 at chunk 3
Cipher B KCipher-2 (RFC 7008), key2 / iv2, byte-continuous keystream across chunks FUN_14007b4e0 (init), FUN_14007c690 (key schedule), FUN_14007c9c0 (update), FUN_14007c8a0 (XOR) The model built from the binary’s tables reproduces RFC 7008 vector C.1 (f871ebef945b7272…) and matched 64 B of live keystream
Key wrap Nettle rsa_encrypt, PKCS#1 v1.5, embedded RSA-4096 key (e = 65537). Input 91 bytes, output 512 bytes, each 32-bit word byte-swapped before storage FUN_14007b8d0, FUN_140081ac0 (DER import), FUN_14007ba90 91-byte input and 512-byte footer write both captured

Two details that cost us time.

The 91-byte RSA plaintext.

Offset Size Content
0 1 0x01
1 1 mode (0 Full, 1 Spot, 2 Part)
2 1 percent (0 in Full mode)
3 8 original file size, little-endian
11 48 key1 and iv1 as 32-bit words in the order k0 k1 v0 k2 k3 v1 k4 k5 v2 k6 k7 v3, each word byte-swapped
59 32 key2 and iv2 interleaved k0 v0 k1 v1 k2 v2 k3 v3, each word byte-swapped

The RSA context has its own Yarrow instance for PKCS#1 padding (FUN_14007bbe0), seeded the same QPC-only way. That does not help recovery, because the private key never touches the victim. Recovery has to come from the symmetric side, and it can.

The embedded public key. RSA-4096, e = 65537, DER at VA 0x1400ee010. SHA-256 of the DER RSAPublicKey: a40a55f2600b9199181bc6c0778e64b2e75145c4e6f715da6d23a72407539aaa; the modulus begins c2f8f4dbbe7da09f7c991d69e00b4466. It identifies the operator key pair; build 2 carries a different one. Both PEM and DER copies are in the case artifacts/.

16 · Windows API map

Call sites come from Ghidra cross-references to 49 imports; the key ones were checked against their callers and decompiles.

Behaviour APIs Call site → function
Read operator arguments GetCommandLineW, CommandLineToArgvW 0x14004cb62, 0x14004cb72 → main FUN_14004ca40
Size thread pools GetSystemInfo 0x14004d549 → main
Drive sweep and classification GetLogicalDriveStringsW, GetDriveTypeW 0x140076786, 0x140076827 → FUN_1400766e0; 0x140042d07 → FUN_140042c50
Network share discovery WNetGetConnectionW, PathIsNetworkPathW 0x140042a97 → FUN_1400429f0; 0x1400433a9 → FUN_140042c50
Folder walk FindFirstFileExW, FindNextFileW, GetFileInformationByHandleEx std::filesystem helpers FUN_1400789d8, FUN_140078998, FUN_140078a4c, used by FUN_1400b81d0
Protected-process snapshot WTSEnumerateProcessesW, WTSFreeMemory 0x140070c79, 0x140070df5 → FUN_140070c30
Kill processes that lock files RmStartSession, RmRegisterResources, RmGetList ×2, RmShutdown, RmEndSession ×3 0x140070eb3–0x1400711c3 → FUN_140070e30; RmShutdown at 0x140071024
Shadow-copy deletion via WMI CoInitializeEx, CoCreateInstance, CoSetProxyBlanket, SysAllocString ×7, VariantInit / VariantClear ×6 0x140071d8d → FUN_140071d80; 0x14007167d, 0x140071704 → FUN_140071620
Wait for the PowerShell child OpenProcess, WaitForSingleObject 0x140071f50, 0x140071f66 → FUN_140071d80 (PID cast to short)
Key seed (the weakness) QueryPerformanceCounter, QueryPerformanceFrequency 0x140078ddd → clock wrapper FUN_140078dd4, called by FUN_14007b1f0 and FUN_14007bbe0; no other entropy source
Open target GetFileAttributesW, SetFileAttributesW, CreateFileW ×2, GetFileSizeEx 0x1400aeea7–0x1400af41e → FUN_1400ae840
Journal CreateFileW 0x1400adc3f → FUN_1400adb00
In-place encryption I/O ReadFile, WriteFile (overlapped), CreateIoCompletionPort, GetQueuedCompletionStatus, PostQueuedCompletionStatus 0x140040993 → FUN_1400407e0; 0x14004075d → FUN_1400405b0; IOCP scheduler FUN_1400385d0, FUN_140038e60
Rename and clean up SetFileInformationByHandle (FileRenameInfo), DeleteFileW 0x1400b1deb, 0x1400b1f02 → FUN_1400ae840

Three imports that look suspicious but are library code:

For hunting, the useful API sequence is WTSEnumerateProcessesW → WMI Win32_Process.Create → RmShutdown → overlapped ReadFile/WriteFile → SetFileInformationByHandle rename.

17 · Measuring the seeds without touching them

The whole recovery rests on two numbers: how far apart a file’s four seeds are, and how far the file’s timestamp sits from them. Our first captures used a breakpoint on each seed, which inflated the gaps to 10–500 ms and made them useless. Run 4 measured them natively.

An in-process logger on the sample’s own clock. The sample reads the counter through one tiny wrapper, FUN_140078dd4, 25 bytes long:

sub  rsp, 28h
lea  rcx, [rsp+30h]
call [QueryPerformanceCounter]
mov  rax, [rsp+30h]          ; <- tail at 0x140078de3 patched: mov rax, stub / jmp rax
add  rsp, 28h
ret

We overwrote its tail with a jump into a small stub in an RWX allocation. The stub reloads the counter value, takes the caller’s return address from [rsp+28h] and the thread ID from gs:[48h], reserves a 32-byte slot with lock xadd, stores (QPC, caller, TID) there and returns the unchanged value. The cost is a few nanoseconds per call and no debug event. To keep the finished process around long enough to read the buffer, the first two bytes of ntdll!RtlExitUserProcess were patched to EB FE (jump to self).

Stub bytes and timer-integrity check
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

We use ScyllaHide, which can hook timing functions, so we checked the timer path in a fresh process with the same debugger profile:

  1. the sample’s IAT entry 0x1400c42f0 points to the kernel32!QueryPerformanceCounter stub (jmp [kernel32 IAT]);
  2. that leads to ntdll!RtlQueryPerformanceCounter with its original prologue (mov [rsp+8],rbx; push rdi; sub rsp,20; mov r8b,[7FFE03C6]);
  3. ntdll!NtQueryPerformanceCounter is the untouched syscall stub (mov r10,rcx; mov eax,31);
  4. SharedUserData+0x3C6 = 0x83: the QPC bypass is enabled, so the counter is read in user mode from the TSC without a syscall.

None of these is hooked, so the logged values are real time. KUSER_SHARED_DATA.QpcFrequency (0x7FFE0300) was 10,000,000, so seed string = QPC × 100.

The safety gates were armed first (WMI executor forced to return, root folder confirmed), then every breakpoint was deleted. During encryption the debugger log shows thread and DLL events only: no exceptions and no breakpoint hits. Two of the 21 resulting files were decrypted with the logged values × 100 as seeds and came back as their all-zero originals, which proves the hook recorded exactly the seeds the sample used.

What the log showed. 129 counter reads, each caller 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 increase monotonically, with two encryptor threads interleaving. The gaps, in 100 ns ticks:

Gap Min Median Max
key1 → iv1 3,916 4,119 4,692
iv1 → key2 3,887 4,047 14,397
key2 → iv2 3,889 4,121 27,109
last seed of one file → first of the next, same thread 15,024 16,513 32,634

The floor of about 3,900 ticks is the time one Yarrow-256 seeding takes on the CPU, so it scales with the victim’s hardware. The rare long gaps come from the two encryptor threads competing for CPU. For key2 → iv2, 18 of 21 gaps were under 5,100 ticks and 20 of 21 under 8,000, which is where recover’s default --gap 3800:8000 comes from.

Timestamps as anchors. Run 4’s 21 files, with their seeds known exactly, let us score every timestamp a responder might have. The live clock offset (precise system time minus QPC, seven pairs agreeing within 12 µs) gave:

Difference Min Median Max
.akira mtime − key1 +1.28 ms +2.34 ms +6.28 ms
.akira mtime − key2 +0.45 ms +1.39 ms +5.36 ms
.akira mtime − iv2 +0.06 ms +0.98 ms +3.82 ms
.akira mtime − coroutine end −1.43 ms −0.53 ms −0.12 ms

The footer write sets the last-write time, just before the coroutine returns. The creation time is the original file’s (the sample overwrites in place) and carries no information. Other anchors from the same run:

Artifact Relation found Use
akira_readme.txt mtime 1.3 ms before the folder’s first key1 seed lower bound for a folder (--floor)
Operator log name local start time to the second coarse run start
Operator log creation time 3.6 ms before the main-start QPC read precise run start, in system time
Operator log last-write time 0.03 ms before the main-end QPC read precise run end, in system time

After a reboot the offset has to come from boot records, and those are coarse:

Offset source Error vs the true offset First-file window
Measured on the live victim, same boot under 0.1 ms −8 / +1 ms, about 42 min in pure Python
WMI LastBootUpTime / Kernel-General 12 StartTime (0.5 s granularity) +352 ms about ±0.6 s, days
Kernel-General 12 TimeCreated +1,497 ms worse
EventLog 6005 TimeCreated +9,620 ms worse still

The error is one constant per boot, though. Once the first file is solved its seeds pin the offset to about ±2.5 ms, and every other file of that boot costs the same as the live case.

18 · Inside recover

akira_decrypt.py is a single Python 3.8+ file using only the standard library (1,601 lines, SHA-256 091701682454a75dfb57324e29a6270977186f68209a7c3a93178ab76fe9fc4b). It never overwrites a file unless told to (--force).

Command Key source Use when
decrypt FILE --keys k1:iv1:k2:iv2 Raw keys (32/16/16/16 bytes hex) Keys came from memory, EDR telemetry or recover
decrypt FILE --seeds s1,s2,s3,s4 The four Yarrow seeds Seeds came from recover or a capture
decrypt PATH --private-key key.pem Operator RSA key (PEM/DER, PKCS#1 or PKCS#8) The key ever leaks; reads mode, percent and keys from each footer and walks whole folders
decrypt FILE --journal FILE.arika … Any of the above An interrupted file; decrypts up to the journal’s progress mark
recover FILE … None: brute-forces the seeds No key material at all
info, benchmark, selftest n/a Show the planned layout; size a search; re-run the validation vectors

Without a footer key the tool infers mode and percent the way the sample does: from the extension lists and the 2,000,000-byte threshold. --percent and --mode override this.

The core is short. Regenerate the four values, rebuild the sample’s chunk plan, and XOR each chunk with the right keystream (condensed from the script):

def keys_from_seeds(seeds):                    # key1, iv1, key2, iv2
    out = []
    for s, n in zip(seeds, (32, 16, 16, 16)):
        y = Yarrow256()                         # fresh instance per value
        y.seed(s.encode("ascii"))              # seed = uptime in ns as text
        out.append(y.random(n))
    return tuple(out)

def regions(mode, percent, size):
    if mode == 0: return [(0, size)]                          # Full
    if mode == 1: return [(0, percent * size // 100)]          # Spot
    div, blocks = (3, 2) if percent < 50 else (5, 4)          # Part
    blk = (percent * size // 100) // div
    stride = (size - blocks * blk) // div
    return [(i * stride, blk) for i in range(blocks)]

def chunk_plan(mode, percent, size):            # same order as the sample
    for start, length in regions(mode, percent, size):
        off = 0
        while off < length:
            n = min(0xFFFF, length - off, size - (start + off))
            cipher = "k2" if off == 0 or length <= off + n else "chacha"
            yield start + off, n, cipher
            off += n

ChaChaSample.xor starts every chunk on a fresh 64-byte block, which reproduces the sample discarding partial blocks; the KCipher-2 keystream runs on across chunks.

The search. Every value is the first 16 bytes of one fresh Yarrow draw, so one table of candidate seeds serves all four values.

  1. Stage 1, KCipher-2. For each key2 seed in the window and each candidate gap to the iv2 seed, run KCipher-2 over the first ciphertext bytes and compare with the known header. At least 7–8 bytes are needed; 8 bytes is a 64-bit filter. A stress run over 20,012,001 pairs found the single true pair with no false positives. This alone solves Spot files under about 262 KB.
  2. Stage 2, ChaCha8. For files with ChaCha8 chunks, walk back from the key2 seed to the iv1 and key1 seeds and test the first ChaCha chunk: known bytes, or the zeros/text heuristic.
  3. Stage 2 fallback for PDFs. If that finds nothing in a .pdf, the same window is searched again with two PDF tests (below).
  4. Chaining. Seeds only increase, so one solved file brackets the next and reveals the real seed-to-seed gap on that machine.

Engines. In pure Python the search runs about 150,000 KCipher-2 pair tests per second on our 20-thread host. With numpy and numba installed, akira_fast.py compiles the stage-1 kernel and reaches 40–97 million pairs per second; building the Yarrow table (about 2,000 seeds per second per core) then becomes the bottleneck. Search cost in pure Python, by how well the key2 seed is known:

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 needs a C or GPU port

The two PDF tests. After stage 1 the KCipher-2 chunks and the unencrypted tail of a PDF are readable, so its structure can be used as known plaintext inside the ChaCha8 chunks:

recover --stage2 auto (the default) runs the generic check first and the PDF tests only when it finds nothing; --stage2 pdf runs only the PDF tests; --stage2 heuristic keeps the old behaviour. A synthetic test suite (test_pdf_stage2.py) covers an xref-solvable PDF, a deflate-solvable PDF, and a binary-stream PDF where neither applies and the tool correctly reports failure.

Validation of the tool itself.

Check Result
Real sample output, all 3 modes, decrypted with captured keys and with captured seeds 6/6 byte-exact
Real sample output, all 3 modes, all four seeds found by recover from a window 3/3 byte-exact
Negative control: key2 seed off by one tick (100 ns) 6,116 non-zero bytes out of a zero file: garbage, as it should be
Negative control: wrong gap window no candidate, no false positive
Built-in self-test (RFC 7008, captured seeds, blobs, chunks, stripe offsets) 8/8
Real-output fixtures 15/15
Round trip with our own RSA-4096 key (footer, PEM/DER/PKCS#8, all modes, journal) 29/29
Synthetic PDF stage-2 cases 3/3

How we ran the sample

All runs were on an isolated Windows VM under x64dbg, reverted to a clean snapshot afterwards. Two safety gates were used in every run from run 2 onward: the WMI executor FUN_140071620 was forced to return (so shadow copies survived), and a breakpoint on the root-folder queue FUN_140073950 confirmed the target before anything was encrypted. The launch line was always --encryption_path=<folder> -localonly.

Run Purpose Outcome
1 First detonation, with -p <path> Escaped the folder because of the argh parsing; stopped early. Taught us the = rule
2 Cipher, mode and layout validation, on zero-filled test files ChaCha8 (128-bit), KCipher-2, all three modes, the Part stripe offsets and the footer position confirmed against live data
3 End-to-end capture: seeds and RSA blobs of three zero-filled files The three real .akira files became the decryptor’s regression fixtures
4 Native seed gap, no seed breakpoints (in-process QPC logger) 84 seeds of 21 files; the gap and timestamp tables above
5 Blind recovery of four real files 4/4 hash-exact
6 Independent blind re-test, rules frozen 6/7 hash-exact blind; 7/7 with the PDF fallback
Build 2 Static comparison, then one run under the run-6 protocol 6/7 hash-exact blind with build 1’s tool unchanged; 7/7 with the PDF fallback

Not covered: --share_file was read statically only, and Part mode with percent below 50 was never exercised.

Function map

Address Role
0x1400855b8 PE entry (CRT)
0x14004ca40 main: logging, CLI parse, drive list, pre-encryption steps, crypto init, thread pools, timing
0x14004c6f0 spdlog file_logger setup (Log-….txt)
0x140053d70 argh-style argv parser
0x1400766e0 Drive enumeration (first five drives only)
0x140042c50 Target classifier: local disk / network disk / network path / not allowed
0x140041fc0 Share-file reader
0x14006ecf0 Adds .akira / .arika to the skip set
0x140070c30 Protected-PID snapshot (WTSEnumerateProcessesW)
0x140071d80, 0x140071620 Shadow-copy command decode; WMI Win32_Process.Create
0x140070e30 Restart Manager unlock / force shutdown
0x1400737d0, 0x140073950 Thread-pool creation; root-folder queue
0x1400b5fb0, 0x1400b81d0 Folder-parser coroutines (exclusions, note drop, mode flags, enqueue)
0x140042420 Ransom-note writer
0x140074480 Enqueue a per-file encryption task
0x1400ae840 Per-file coroutine: open, cipher init, journal, mode dispatch, footer, rename
0x1400adb00 .arika journal creation (532-byte header)
0x1400b23a0 / 0x1400b3580 / 0x1400b4780 Full / Spot / Part encryption loops
0x1400b5a20 512-byte footer writer
0x140036740 Per-file key generation and RSA wrap of the 91-byte blob
0x140078dd4 QPC wrapper (the only clock the keys see)
0x14007b1f0 Per-value Yarrow-256 instance seeded from QPC
0x14007bbe0 RSA-padding Yarrow, seeded the same way
0x14007b0b0, 0x14007c570, 0x14007beb0 ChaCha init / key setup / 8-round block function
0x14007b4e0, 0x14007c690, 0x14007c9c0, 0x14007c8a0 KCipher-2 init / key schedule / update / keystream XOR
0x14007ba90, 0x140081ac0, 0x14007b8d0 RSA public-key import (DER) and PKCS#1 v1.5 encrypt
0x140003680, 0x140034500, 0x1400018a0, 0x140001ac0 Static initializers: DB-extension, VM-extension, folder and extension sets

19 · Indicators of compromise

All indicators come from the binaries or from our controlled runs. The binary has no network, registry, mutex or service indicators: it opens no sockets, writes no registry keys and creates no named mutex.

TypeValueContext
SHA-256101bd45ab673fd6547386dff4339f1649e61b9f5c318260d365f766fbdc2d3c9sample
MD5427eba9f6ff2cd312c72770f13e279c1sample
PE timestamp0x658D8B152023-12-28 14:49:57 UTC
SHA-256988776358d0e45a4907dc1f4906a916f1b3595a31fa44d8e04e563a32557eb42second build, same codebase
MD54aecef9ddc8d07b82a6902b27f051f34second build
Victim code6729-HK-NIZN-WOPQchat login in the second build’s note
RSA key (DER SHA-256)a40a55f2600b9199181bc6c0778e64b2e75145c4e6f715da6d23a72407539aaaembedded RSA-4096 public key; modulus begins c2f8f4dbbe7da09f7c991d69e00b4466
Onionakiral2iz6a7qgd3ayp3l6yub7xx2uep76idk3u2kollpj5z3z636bad.onionleak site in the note
Onionakiralkzxzq2dsrzsrvbr2xgbbu2wgsmxryd4csgfameg52n7efvr2id.onionnegotiation chat in the note
Victim code6188-EA-ABYW-YGRGchat login in the note
Extension.akiraencrypted file, original size + 512 B, last 512 B high-entropy
Extension.arikatemporary 532-byte per-file journal
File nameakira_readme.txtransom note, one per folder
File nameLog-DD-MM-YYYY-HH-MM-SS.txtoperator log in the working directory
Command linepowershell.exe -Command "Get-WmiObject Win32_Shadowcopy | Remove-WmiObject"hidden, parent WmiPrvSE.exe
Arguments--encryption_path= --share_file= --encryption_percent= -localonlyoperator launch syntax
Log stringsNumber of threads to encrypt = This is network disk: Failed to make part encrypt! ( asio::create_auto_save_file error:static strings in the binary

20 · Catch it before the first file

The earliest reliable signal is the shadow-copy deletion: PowerShell started by WmiPrvSE.exe with Win32_Shadowcopy and Remove-WmiObject on its command line. It happens before any file is touched. After that, a burst of .arika creations followed by .akira renames is unmistakable.

Signal Source Why it fires
WmiPrvSE.exe → powershell.exe with Win32_Shadowcopy and Remove-WmiObject Process creation (Sysmon 1 / 4688), PowerShell 4104 Shadow-copy deletion via Win32_Process.Create, hidden window
Bursts of *.arika creates, then the same paths renamed to *.akira File events (Sysmon 11 / EDR rename telemetry) Per-file journal + FileRenameInfo rename
akira_readme.txt created in many folders within seconds File events Note dropped per traversed folder
Rapid Restart Manager sessions from an unsigned process, then terminations of database or Office processes Application log RestartManager 10000/10001, process termination RmShutdown(RmForceShutdown) on file lockers
Command line containing --encryption_path=, --share_file= or -localonly Process creation Operator launch syntax
New Log-??-??-????-??-??-??.txt next to an unknown executable File events Built-in operator log

YARA. The rule uses only strings and constants confirmed present in this binary. It has not been run against a wider corpus.

rule Akira_Win64_Encryptor_101bd45a
{
    meta:
        description = "Akira-branded x64 encryptor (asio/C++20, ChaCha8 + KCipher-2, RSA-4096)"
        sha256      = "101bd45ab673fd6547386dff4339f1649e61b9f5c318260d365f766fbdc2d3c9"
        date        = "2026-10-11"
    strings:
        $n1 = "akira_readme.txt" ascii
        $n2 = ".arika" ascii
        $l1 = "write_encrypt_info error:" ascii
        $l2 = "(Full begin) Cipher is not initialize!" ascii
        $l3 = "asio::create_auto_save_file error:" ascii
        $l4 = "Failed to make spot encrypt! (" ascii
        $l5 = "Number of thread to root folder parsers = " ascii
        $c1 = "--encryption_percent" wide
        $c2 = "--share_file" wide
        $c3 = "-localonly" wide
        $k2 = { 00 00 00 00 1A 6D 08 B6 34 DA 10 AF 2E B7 18 19 }   // KCipher-2 amul0 table
        $cc = "expand 32-byte kexpand 16-byte k" ascii
    condition:
        uint16(0) == 0x5A4D and filesize < 3MB and
        ( (1 of ($n*) and 4 of ($l*, $c*)) or ($k2 and $cc and 3 of ($l*, $c*)) )
}
ATT&CK Technique In this sample
T1486 Data Encrypted for Impact ChaCha8 + KCipher-2, RSA-4096 footer
T1490 Inhibit System Recovery Volume Shadow Copies removed
T1047 Windows Management Instrumentation Win32_Process.Create starts PowerShell
T1059.001 PowerShell shadow-copy removal
T1564.003 Hidden Window Win32_ProcessStartup.ShowWindow = 0
T1489 Service Stop Restart Manager closes file holders
T1057 Process Discovery WTSEnumerateProcessesW protected-PID list, RmGetList
T1083 File and Directory Discovery recursive directory iteration
T1135 Network Share Discovery mapped-drive and UNC classification; share-list input
T1082 System Information Discovery GetSystemInfo CPU count sizes the thread pools
T1027 Obfuscated Files or Information per-string mod-127 obfuscation of three string groups

21 · Decryptor, scripts and rules

The tools are standard-library Python 3.8+ and PowerShell.

Artifact What it is
akira_decrypt.py Decryptor and seed brute-forcer: decrypt, recover, benchmark, selftest. 2026-10-14 version with the PDF stage-2 fallback.
akira_fast.py Optional compiled search engine for recover (needs numpy + numba): about 20 s per file instead of 30 min.
collect_clock_and_times.ps1 Responder collector: clock pairs, file timestamps, ransom notes and the operator log.
run6_blind_recover.py Blind recovery for a whole folder: type-based known bytes (PNG, PDF, ZIP family, OLE2, JFIF, SQLite, RTF), the default pass, then an automatic second pass.
run5_blind_recover.py End-to-end example of the run-5 blind recovery, including the DOCX header rebuild.
analyze_timestamps.py Matches encrypted files to seeds and measures timestamp offsets.
PDF stage-2 investigation How the run-6 PDF was recovered (deflate test); the build-2 xref test is in the build-2 case.
akira_win64_encryptor_101bd45a.yar YARA rule for this build.
Sigma rules Shadow-copy removal by WMI-spawned PowerShell (rule), and the operator launch syntax (rule).
iocs.csv All indicators from this post in one file.
Tests Built-in checks, real-output fixtures, round-trip and PDF stage-2 tests.
Case report The full technical report in one Markdown file, with the per-step notes beside it in the case folder.

Shayan Ahmed Khan · Lead Threat Detection & Response

All analysis was performed on an isolated lab VM that was reverted after every run. Findings are from our own reverse engineering and controlled detonations of the samples identified above.

← Back to all analyses