Yen Wang

0x20Writing

Reversing Akira's Rust Experiment: A Breakdown, Part 3

A breakdown and commentary on Check Point Research's analysis of Akira ransomware's Rust rewrite.

Published 27 May 2026 Read 34 min Tags malware, rust, reverse-engineering

Originally published on RE: Exploit (Medium). Read the original ↗

This post is a breakdown and commentary on Ben Herzog’s Check Point Research (December 2024), Inside Akira Ransomware’s Rust Experiment & his talk in 2025. All technical claims are grounded in the linked primary sources below. Combined with some methodologies of Cindy Xiao’s amazing reports/talks in 2024, 2025 (mainly for reconstructing types).

If you haven’t read Part 1 and Part 2, it’s encouraged to read the threat reports at least (see References — Threat Reports below), so the activities of the binary are clear alongside the binary analysis, including the latest Tactics, Techniques, and Procedures (TTPs).

Table of Contents

  • Info of the binary in this analysis
  • Section 5: lock and Thread Spawning
  • Section 6: Lock_Closure and Encryption

Further Reading

    1. Why UAWAVAUATSH exists
    1. REX — Register Extension.
    1. Mutex

References

Reversing Akira’s Rust Experiment: A Breakdown Part 1

This post is a breakdown and commentary on Ben Herzog’s Check Point Research (December 2024), Inside Akira Ransomware’s Rust Experiment & his talk in 2025. All technical claims are grounded in the linked primary sources below. Combined with Cindy Xiao’s amazing reports/talks in 2024, 2025 (about reconstructing types and more).

There will be a part 2 to break down the rest. I separate them so this article won’t be _too_long_.

For this analysis, Binary Ninja (v5.3.9434) is used without enabling any plugins. Binary: akirav2.elf , SHA25:60ee1d284ed663073872012c7bde7fac5ca1121403f1a5d2d5411317df282796c.

A note on source references

The commit hash is updated on 10th Jun 2026. 79e9716c… is rustc 1.74.0 stable (released 2023-11-16). Akirav2’s .rodata contains std panic-path strings of the form /rustc//library/....db034cee… is a master/nightly commit from ~1 year later, which I was mistakenly pinned in the article. If you see any links related to db034cee… , please consider it was based on previosu wrong assumption but it works to some degree.

The Rust standard library is under active development and files move between releases. Source links in this post are pinned to commit 79e9716c980570bfd1f666e3b16ac583f0168962 — rustc 1.74.0 stable, the toolchain that compiled this Akira v2 binary. This is recoverable directly from the binary: its .rodata embeds /rustc/79e9716c…/library/... panic paths, and it is corroborated by version-pinned FunctionID analysis resolving the sample to 1.74.0.

This matters because stdlib code is inlined directly into the final binary at compile time: what you see in the disassembly reflects the stdlib at that specific commit, not the current main branch. Following a newer version of the same file risks misidentifying inlined boilerplate or drawing incorrect conclusions about what the malware authors wrote versus what the compiler contributed.

If a pinned link returns a 404, the file has likely been relocated. Navigate to the repository root and search by filename.

Section 5: lock and Thread Spawning

After flag processing, default_action calls lock, which is a thin wrapper. Its job is to spawn threads worker threads, each running lock_closure, and then join them. The structure mirrors yarnish.rs almost exactly, with the indicatif MultiProgress handle passed into each thread via Arc.

Xref panel of <code>lock</code>

Xref panel of `lock`

This binary is a bit different from the CheckPoint report that I didn’t find tick_chars(“⠁⠂⠄⡀⢀⠠⠐⠈ “) . Instead, it showed up as UTF-8 encoding.

If you check the source code of yarnish.rs :

pub fn main() {
    let mut rng = rand::rng();
    let started = Instant::now();
    let spinner_style = ProgressStyle::with_template("{prefix:.bold.dim} {spinner} {wide_msg}")
        .unwrap()
        .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ");

In .rodata section, all the strings are combined without null-terminator

In .rodata section, all the strings are combined without null-terminator

In <code>default_action</code>, the structure is clearer with the string slice

In `default_action`, the structure is clearer with the string slice

If you check the disassembly of default_action , you can see the pattern of source code yarnish.rs aligned in order.

Where are the tick_chars ?

The real situation

The tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ") call is compiled in, but the Braille spinner string is what is not finding. The figure above shows .rodata at 0x4414eb containing e2 a0 81 e2 a0 82 e2 a0 84 e2 a1 80 e2-a2 80 e2 a0 a0 e2 a0 90 followed by e2 a0 88 20 at 0x441500.

Decoding those as UTF-8:

e2 a0 81 = ⠁   (U+2801)
e2 a0 82 = ⠂   (U+2802)
e2 a0 84 = ⠄   (U+2804)
e2 a1 80 = ⡀   (U+2840)
e2 a2 80 = ⢀   (U+2880)
e2 a0 a0 = ⠠   (U+2820)
e2 a0 90 = ⠐   (U+2810)
e2 a0 88 = ⠈   (U+2808)
20       = (space)

That is exactly "⠁⠂⠄⡀⢀⠠⠐⠈ ", the tick string from yarnish.rs, present verbatim in .rodata. Binary Ninja has labelled this region data_4414eb and is rendering the bytes as literal dots rather than recognising the Braille Unicode. The string is there; it is a display/encoding issue in how Binary Ninja is showing that .rodata range.

For yarnish.rs reconstruction

The tick_chars call and its argument string are both present in the binary. Your reconstructed source should include .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "). The chain is with_template(...).unwrap().tick_chars(...), matching yarnish.rs exactly.

RE relevance: this is a clean example of a Unicode string literal surviving into .rodata with full fidelity, recoverable by UTF-8 decoding of the raw hex even when the decompiler renders it as garbage. Worth noting as a corpus evidence tier (.rodata string recovery) in your detection pipeline.

Reconstructing the lock function

Before beginning reconstruction, the build environment is fixed from binary evidence.

The source file for lock is confirmed as akiranew/src/lock.rs from the .data.rel.ro log format metadata at 0x5e7028:

"akiranew/src/lock.rs#>- File crypting... File lock started: , thread id: ..."

The Graphic Overview of lock

The yarnish.rs template

The structural origin of lock is based onindicatif's yarnish.rs example. The relevant section is:

let handles: Vec<_> = (0..4u32)
    .map(|i| {
        let count = rng.random_range(30..80);
        let pb = m.add(ProgressBar::new(count));
        pb.set_style(spinner_style.clone());
        pb.set_prefix(format!("[{}/?]", i + 1));
        thread::spawn(move || {
            // ... work ...
            pb.finish_with_message("waiting...");
        })
    })
    .collect();
for h in handles {
    let _ = h.join();
}
m.clear().unwrap();

The Akira authors adopted this skeleton and replaced the dummy work body with encryption logic, the fixed range (0..4u32) with an iterator over the file list, and the thread count with a runtime semaphore. The differences are documented in each step below.

Step 1 — Function prologue and argument layout

Address range: 0x4d38a5 -- 0x4d38d2

push rbp, r15, r14, r13, r12, rbx
sub  rsp, 0x2a8

The frame size is 0x2a8 (680 bytes). Six callee-saved registers are preserved, consistent with a function that holds many live values across multiple call sites. The following comments are based on Checkpoint report, if you was investigating without reviewing it, you will see more clues later.

int64_t* akira_v2::lock::lock(
    int64_t* arg1,   // Vec<PathBuf> -- the file list
    int64_t  arg2,   // thread limit (u64)
    char*    arg3,   // exclude regex (*const u8)
    int64_t* arg4    // operator options struct
)

The compiler saves the incoming registers to the stack immediately after the prologue. The System V AMD64 ABI, which governs all Linux x86–64 binaries including akirav2.elf, specifies that the first six integer or pointer arguments are passed in registers rdi, rsi, rdx, rcx, r8, r9 in that order (x86-psABIs project, 2024, §3.2.3).

The authoritative primary source is the System V Application Binary Interface AMD64 Architecture Processor Supplement, version 1.0. Maintained at with the compiled PDF at github.com/hjl-tools/x86-psABI/wiki/x86–64-psABI-1.0.pdf, section 3.2.3, Figure 3.4 (register usage table: %rdi = 1st argument, %rsi = 2nd, %rdx = 3rd, %rcx = 4th).

The save sequence in the binary is:

mov  r12,  rdi                           ; r12    = arg1 (rdi)
mov  qword [rsp+0x188 {var_150}], rsi   ; var_150 = arg2 (rsi)
mov  qword [rsp+0x38  {var_2a0}], rdx   ; var_2a0 = arg3 (rdx)
mov  qword [rsp+0x40  {var_298}], rcx   ; var_298 = arg4 (rcx)

The compiler does not save in ABI order, it schedules saves however suits instruction throughput. The mapping from ABI position to stack slot must be read from the registers, not from the order of the mov instructions.

arg1 is Vec confirmed from the later imul rcx, [r12+0x10], 0x18 (capacity * 24, where 24 = sizeof(PathBuf) = ptr + len + cap = 3 × 8 bytes).

The semantic labels for arg2 (rsi / var_150), arg3 (rdx / var_2a0), and arg4 (rcx / var_298) are training-derived, which of these holds the thread limit, the exclude regex pointer, and the operator options struct requires tracing the read sites of each stack variable through the function body. Binary Ninja's resolved signature labels them arg2: int64_t, arg3: char*, arg4: int64_t*, but the semantic assignment cannot be confirmed from the prologue alone.

Step 2 — ProgressStyle initialisation

Address range: 0x4d38cb -- 0x4d390c

This is the direct binary equivalent of yarnish.rs:

let spinner_style = ProgressStyle::with_template("...").unwrap().tick_chars("...");
lea  rsi, [rel data_441490[0x24]]   ; template string in .rodata
lea  r15, [rsp+0x100 {var_1d8}]     ; destination on stack
push 0x37 / pop rdx                 ; template length = 55 bytes
mov  rdi, r15
call indicatif::style::ProgressStyle::with_template  ; 0x4d38e0

The with_template call returns Result<ProgressStyle, TemplateError>. The Result-returning signature confirms indicatif >= 0.17.0 (versions prior to 0.17 returned ProgressStyle directly). The .rodata panic path confirms 0.17.7 exactly.

call core::result::Result::unwrap    ; 0x4d38f4
call indicatif::style::ProgressStyle::tick_chars  ; 0x4d3904

The completed ProgressStyle lives in r15 / var_1d8 for the duration of the function.

Step 3 — MultiProgress and Arc allocation

Address range: 0x4d390c -- 0x4d3951

Two consecutive exchange_malloc calls allocate the MultiProgress and its internal shared state. Both follow the same Arc field initialisation pattern:

call alloc::alloc::exchange_malloc  ; size=0x8, align=0x8 (first call)
mov  qword [rax],     r13  {0x1}    ; ArcInner.strong = 1
mov  qword [rax+0x8], r13  {0x1}    ; ArcInner.weak   = 1
and  dword [rax+0x10], 0x0          ; ArcInner.data zeroed

The field offsets are Verified from the pinned stdlib source at library/alloc/src/sync.rs. ArcInner is declared #[repr(C)], which guarantees field order matches declaration order. The fields in source order are:

#[repr(C)]
struct ArcInner<T: ?Sized> {
    strong: atomic::AtomicUsize,   // +0x00, 8 bytes on x86-64
    weak:   atomic::AtomicUsize,   // +0x08, 8 bytes on x86-64
    data:   T,                     // +0x10 (for T with align <= 8)
}

Arc::new initialises both counts to 1, confirmed in the same source file:

let x: Box<_> = Box::new(ArcInner {
    strong: atomic::AtomicUsize::new(1),
    weak:   atomic::AtomicUsize::new(1),
    data,
});

The #[repr(C)] annotation is load-bearing: without it, Rust's default repr(Rust) would permit field re-ordering and the offset claim would not be verifiable from source inspection alone.

call alloc::alloc::exchange_malloc   ; size=0x30, align=0x8 (second call)
; same initialisation pattern, larger payload

The 48-byte second allocation is the MultiProgress inner state. These correspond to MultiProgress::new() in yarnish.rs. No MultiProgress::new symbol survives in the binary because the constructor is fully inlined.

Step 4 — File iterator construction

Address range: 0x4d397d -- 0x4d39b0

This is the single most important structural divergence from yarnish.rs. The yarnish.rs example iterates (0..4u32) which is a fixed range. Akira iterates the file list directly:

mov  rax, qword [r12]           ; rax = files.ptr
mov  rdx, qword [r12+0x8]       ; rdx = files.len
imul rcx, qword [r12+0x10], 0x18  ; rcx = files.cap * 24
add  rcx, rax                   ; rcx = end pointer (ptr + cap*stride)
mov  qword [r15 {var_1d8}],       rax  ; IntoIter.buf   = ptr
mov  qword [r15+0x8 {var_1d0}],   rdx  ; IntoIter.len   = len
mov  qword [r15+0x10 {var_1c8}],  rax  ; IntoIter.start = ptr
mov  qword [r15+0x18 {i_1}],      rcx  ; IntoIter.end   = end ptr

The imul multiplier 0x18 (24) equals sizeof(PathBuf) on x86-64: PathBuf = Vec = (ptr: *mut u8, len: usize, cap: usize) = 3 × 8 = 24 bytes. This construction is the IntoIter struct being built on the stack.

Loop head at 0x4d39b0:

cmp  rax, rcx   ; current_ptr == end_ptr?
je   0x4d3a22   ; yes → drop IntoIter and exit

Loop exit at 0x4d3a22:

call _$LTalloc..vec..into_it...op..Drop$GT$::drop

The IntoIter destructor runs when the file list is exhausted. This is the outer boundary of the entire map(...).collect() equivalent.

Step 5 — Per-iteration seed generation (Akira-specific)

Address range: 0x4d3c69 -- 0x4d3d1c

There is no equivalent in yarnish.rs. This block is unique to Akira.

SystemTime::now and duration_since(symbol names read directly from disassembly). Signatures are defined in the pinned stdlib at library/std/src/time.rs. SystemTime::now() returns the current wall-clock time. SystemTime::duration_since returns Result<Duration, SystemTimeError>; the jne 0x4d42de at 0x4d3c82 is the error branch for the SystemTimeError case (time went backwards).

call std::time::SystemTime::now         ; 0x4d3c69 -- Verified
mov  qword [rsp+0x50 {var_288}], rax    ; store seconds
mov  dword [rsp+0x58 {var_280_1}], edx  ; store subsec_nanos
mov  rdi, r12  {var_1d8}
mov  rsi, r13  {var_288}
call std::time::SystemTime::duration_since  ; 0x4d3c7d -- Verified
cmp  qword [rsp+0x100 {var_1d8}], 0x0
jne  0x4d42de                           ; error path

getpid(symbol name read directly from disassembly). getpid() is a POSIX system call returning the calling process's PID as pid_t (a 32-bit integer on Linux x86-64), returned in eax (Linux man page: getpid(2)).

call qword [rel getpid]                 ; 0x4d3c99 -- Verified
mov  dword [rsp+0x98 {var_240_1}], eax  ; pid stored as u32
mov  byte  [rsp+0xa8 {var_230_1}], 0x1
mov  qword [rsp+0x9c {var_23c_1}], 0xde ; constant -- see note below
and  dword [rsp+0xa4 {var_234_1}], 0x0

Note on 0xde: the constant at +0x9c is binary-observed. Its semantic meaning whether it is a version tag, a seed format discriminant, or an unrelated constant is unknown and cannot be determined without source code for akiranew.

HashStruct::hash (mangled symbol name read from disassembly). This is an internal function in the akiranew workspace crate; no public source is available. The 64-byte output size is hand-calculated from the four consecutive movups instructions each loading 16 bytes (4 × 16 = 64):

call akira_v2::prng::HashStruct::hash::h869e36ed471afafc  ; 0x4d3ce0 -- Verified (symbol)
movups xmm0, xmmword [rsp+0x50 {var_288}]   ; bytes  0-15  -- Hand-calculated
movups xmm1, xmmword [rsp+0x60 {var_278}]   ; bytes 16-31
movups xmm2, xmmword [rsp+0x90 {var_248_1}] ; bytes 32-47
movups xmm3, xmmword [rsp+0xa0 {var_23c_1+0x4}] ; bytes 48-63

The interpretation of these 64 bytes as encryption key material is functional inference, the output is passed into the closure capture struct (Step 8) and ultimately into lock_closure, but the claim that it constitutes a key rather than, say, an IV or a nonce requires further analysis of lock_closure to confirm.

Log string evidence (binary-observed from .data.rel.ro at data_5e7018 / 0x5e7018): the string "File crypting... File lock started: , thread id: ..." is emitted from akiranew/src/lock.rs and references a thread ID, consistent with getpid() being used to identify threads. This is corroborating evidence, not proof of the key derivation mechanism.

Step 6 — ProgressBar construction and MultiProgress::add

Address range: 0x4d3d29 -- 0x4d3d83

call alloc::alloc::exchange_malloc  ; size=0x58 (88 bytes) -- ProgressBar Arc
mov  qword [rax],     r15  {0x1}    ; strong = 1
mov  qword [rax+0x8], r15  {0x1}    ; weak   = 1
and  dword [rax+0x10], 0x0
mov  byte  [rax+0x14], 0x0
lea  rdi,  [rax+0x15]
push 0x43 / pop rdx  {0x43}         ; length = 67 -- the file path string length
call qword [rel data_611b10] {memcpy}  ; copy file path into ProgressBar prefix

The file path (67 bytes) is copied directly into the ProgressBar internal buffer. In yarnish.rs, the prefix is format!("[{}/?]", i + 1). In Akira, it is the target file path, giving the operator a live view of which file is being encrypted on each progress bar line.

call indicatif::draw_target::...DrawTarget::stderr   ; 0x4d3d65
call indicatif::progress_bar::...::with_draw_target  ; 0x4d3d73
call indicatif::multi::MultiProgress::add            ; 0x4d3d83

This is let pb = m.add(ProgressBar::new(count)) from yarnish.rs, expanded into three calls because ProgressBar::new and the draw target setup are not merged by the compiler at this optimisation level. The result (the managed ProgressBar) is held in r13 / var_288.

Step 7 — Shared mutex and thread-limit semaphore

Address range: 0x4d3d91 -- 0x4d3dab

Mutex::lock and Result::unwrap

The source for Mutex::lock at the pinned commit is at library/std/src/sync/mutex.rs .

call std::sync::mutex::Mutex<T>::lock   ; 0x4d3d91 -- Verified
call core::result::Result::unwrap       ; 0x4d3da3 -- Verified

The mutex at data_5ebe00 is a static object in .data.rel.ro. The claim that it is distinct from the MultiProgress internal mutex is a functional inference based on two different static addresses appearing at two different mutex call sites in the disassembly. What data_5ebe00 actually guards requires tracing its read sites, the thread-limit interpretation is not binary-confirmed.

Further into the loop body:

call std::thread::scoped::Sco...um_running_threads::hb34fb126c362d0f3  ; 0x4d4062

std::thread::scoped::Scope::num_running_threads.

The function lives inside ScopeData in library/std/src/thread/scoped.rs:

pub(super) struct ScopeData {
    num_running_threads: Atomic<usize>,
    a_thread_panicked:   Atomic<bool>,
    main_thread:         Thread,
}

Scoped threads were stabilised at Rust 1.63.0, confirmed by the #[stable(feature = "scoped_threads", since = "1.63.0")] attribute visible in the same source file. The toolchain used to compile akirav2.elf is nightly-2024-10-06, which is well past 1.63, so the stable API is available.

The claim that num_running_threads is used as a semaphore enforcing arg2 as a thread limit is functional inference, the observation is that num_running_threads is called before Thread::new in the loop body, which is consistent with a throttle. The exact blocking mechanism (whether the loop spins, parks, or uses a condition variable, A.K.A. condvar) requires further disassembly of the code between 0x4d4062 and 0x4d3e7e.

Step 8 — Closure capture struct construction

Address range: 0x4d406d -- 0x4d40a0

Review <code>r13</code>

Review `r13`

exchange_malloc size and alignment:

call alloc::alloc::exchange_malloc   ; size=0x58 (88 bytes), align=0x8

The field mapping for closure fields +0x00, +0x08, +0x10 is therefore:

+0x00  r14  -- (ptr-sized value)
+0x08  rbx  -- (ptr-sized value)
+0x10  r13  -- output_capture: Option<Arc<Mutex<Vec<u8>>>> (return value of set_output_capture at 0x4d4026)
+0x18  xmm0 -- 16 bytes from HashStruct::hash output
+0x28  rcx  -- pointer (var_268)
+0x30  rcx  -- pointer (var_2b0)
+0x38  rcx  -- pointer (var_148_1)

+0x10 resolved: At 0x4d4026, r13 is assigned the return value of std::io::stdio::set_output_capture, which returns Option<Arc<Mutex<Vec>>>, the previous stdout capture handle. The test rax, rax / je 0x4d403e / lock inc qword [r13] sequence immediately after (binary-observed) is the Option handling: if Some, bump the refcount; if None, skip. This matches the output_capture variable captured by the main shim closure in the verified thread.rs source at the pinned commit:

crate::io::set_output_capture(output_capture);

PathBuf layout

The layout of PathBuf on x86_64-unknown-linux-gnu at the pinned commit is (ptr, len, cap) = 3 × 8 = 24 bytes.

The proof chain is:

PathBuf { inner: OsString }
  → OsString { inner: Buf }
    → #[repr(transparent)] pub struct Buf { pub inner: Vec<u8> }
      → Vec<u8> = (ptr: *mut u8, len: usize, cap: usize)

The #[repr(transparent)] on Buf is the load-bearing guarantee: it makes the Vec equivalence a language specification rather than a compiler implementation detail. The two outer single-field structs (PathBuf, OsString) are not formally repr(transparent) and their layout is technically an implementation detail, but the chain is consistent with the imul rcx, [r12+0x10], 0x18 stride evidence in Step 4.

The field mapping for closure fields +0x00, +0x08, +0x10 is therefore:

+0x00  r14  -- Vec<u8>.ptr  (PathBuf data pointer)
+0x08  rbx  -- Vec<u8>.len  (PathBuf byte length)
+0x10  r13  -- output_capture: Option<Arc<Mutex<Vec<u8>>>> (set_output_capture return value -- see above)

Note that +0x10 is not Vec.cap. The r13 register was loaded from set_output_capture at 0x4d4026, not from the PathBuf capacity field. The capacity field of PathBuf is not stored in the closure -- only ptr and len are needed to reference the file path slice inside lock_closure.

+0x18 key material: Functional inference. The movups xmmword [rax+0x18], xmm0 stores the value in xmm0, which was loaded from the HashStruct::hash output region in Step 5. This is a register-trace inference across intervening instructions, not a direct binary observation of the key derivation path end-to-end.

Fields beyond +0x38. The exact field count and types beyond +0x38 require further disassembly of lock_closure.

Step 9 — First Thread::new: the encryption thread

Address range: 0x4d3e6f -- 0x4d3e91

Disassembly

Disassembly

HLIL in Binary Ninja

HLIL in Binary Ninja

Starting from High Level IL

**Thread::new call site (**0x4d3e7e):

char const (** rax_24)[0x28] =
    std::thread::Thread::new::h977b0e8df4ff7524()

Binary Ninja has typed rax_24 as char const (**)[0x28], a pointer to a pointer to a 0x28-byte (40-byte) array. This is Binary Ninja's best-effort type inference for the return value. The key observation is that rax_24 is a pointer, not an integer. Thread::new is returning a pointer in rax.

0x4d3e83:

char const (** var_2c0)[0x28] = rax_24

The return value is stored into var_2c0.

0x4d3e8d:

char const (* temp5_1)[0x28] = *rax_24
*rax_24 = &(**rax_24)[1]

This is the lock inc in HLIL form. Binary Ninja has lifted it as: dereference rax_24 to get temp5_1 (the current value), then write (**rax_24)[1] back, i.e., increment the qword at the address rax_24 points to. This is an atomic increment of a counter at the address returned by Thread::new.

0x4d3e91:

if (temp5_1 s> -1)

This is the jle 0x4d42da overflow check. The s> -1 (signed greater than -1) is the jle taken when the incremented value was ≤ -1 (i.e., overflow/underflow). This is exactly the Arc refcount overflow guard from sync.rs:

if old_size > MAX_REFCOUNT { abort(); }

0x4d3e9a: the else branch (overflow path):

uint64_t rax_25 = alloc::alloc::exchange_malloc::hc6dbbe43d4abbe5a .1241
*rax_25 = 1
*(rax_25 + 8) = 1
*(rax_25 + 0x10) = zx.o(0)
var_268 = rax_25

A fresh allocation of size .1241 (some allocation size). Two fields set to 1, third field zeroed. This is another Arc allocation: strong=1, weak=1, data=0. The var_268 assignment is storing this new Arc pointer. This is the JoinHandle's packet Arc being allocated in the success path of Thread::new.

Then Disassembly

call std::sys_common::thread::min_stack   ; 0x4d3e6f
mov  qword [rsp+0x1b0 {var_128_1}], rax
xor  edi, edi  {0x0}                      ; stack = 0 (use default)
call std::thread::Thread::new             ; 0x4d3e7e

std::thread::Thread::new is observed at 0x4d3e7e. The symbol name is read directly from the disassembly. The pinned source is at library/std/src/sys/pal/unix/thread.rs:

pub unsafe fn new(stack: usize, p: Box<dyn FnOnce()>) -> io::Result<Thread>

The xor edi, edi setting stack = 0 is verified against the Thread::new source: when stack = 0 is passed, the implementation uses cmp::max(0, min_stack_size(&attr)), which collapses to min_stack_size. min_stack is called at 0x4d3e6f immediately before to cache that value.

The implementation details (double-boxing, Box::into_raw(Box::new(p)), pthread_create, thread_start) are Verified from library/std/src/sys/pal/unix/thread.rs at commit db034cee:

let p = Box::into_raw(Box::new(p));

and thread_start is the extern "C" callback at the end of Thread::new in the same file. After Thread::new returns:

mov  qword [rsp+0x18 {var_2c0}], rax
lock inc qword [rax]                ; Arc refcount bump

lock inc: Binary Ninja HLIL evidence. The HLIL at 0x4d3e7e--0x4d3e91 reads:

char const (** rax_24)[0x28] =
    std::thread::Thread::new::h977b0e8df4ff7524()
char const (** var_2c0)[0x28] = rax_24
char const (* temp5_1)[0x28] = *rax_24
*rax_24 = &(**rax_24)[1]          // ← this is the lock inc
if (temp5_1 s> -1) { ... }        // ← overflow guard

The overflow guard if (temp5_1 s> -1) matches the Arc refcount overflow check in pasted alloc/src/sync.rs:

if old_size > MAX_REFCOUNT { abort(); }

where MAX_REFCOUNT = (isize::MAX) as usize .

On x86–64, isize::MAX = 2^63 - 1 = 9_223_372_036_854_775_807 = 0x7fff_ffff_ffff_ffff.

At the pinned commit, Thread is defined in library/std/src/thread/mod.rs as:

pub struct Thread(Inner);

enum Inner {
    Main(&'static (ThreadId, Parker)),
    Other(Pin<Arc<OtherInner>>),
}

The Arc being incremented by lock inc is therefore the Arc inside Inner::Other, accessed via the Thread handle returned from Thread::new.

The subsequent basic block at 0x4d3e9a allocates JoinInner::packet :

struct JoinInner<'scope, T> {
    native: imp::Thread,
    thread: Thread,
    packet: Arc<Packet<'scope, T>>,
}

The Arc is the communication channel between the spawned thread and its JoinHandle.

Then immediately:

xor  edi, edi
call std::io::stdio::set_output_capture   ; 0x4d3ec4

This is inside the inlined main shim closure body. The main shim is constructed in Builder::spawn_unchecked_ in library/std/src/thread/mod.rs . The shim at db034cee is:

let main = move || {
    if let Err(_thread) = set_current(their_thread.clone()) {
        rtabort!("something here is badly broken!");
    }
    if let Some(name) = their_thread.cname() {
        imp::Thread::set_name(name);
    }
    crate::io::set_output_capture(output_capture);
    let f = f.into_inner();
    let try_result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
        crate::sys::backtrace::__rust_begin_short_backtrace(f)
    }));
    ...

set_output_capture is defined at library/std/src/io/stdio.rs. It returns Option<Arc<Mutex<Vec>>>, the previous capture state. xor edi, edi passes None (null pointer as the first argument), confirmed by the SysV ABI (rdi = first argument, cited in Step 1 via x86-psABIs v1.0 §3.2.3).

Step 10 — Second Thread::new: the indicatif ticker thread

Address range: 0x4d3fbf -- 0x4d4016

yarnish.rs line pb.set_style(spinner_style.clone()):

let handles: Vec<_> = (0..4u32)
        .map(|i| {
            let count = rng.gen_range(30..80);
            let pb = m.add(ProgressBar::new(count));
            pb.set_style(spinner_style.clone());
            pb.set_prefix(format!("[{}/?]", i + 1));
            thread::spawn(move || {
                let mut rng = rand::thread_rng();
                let pkg = PACKAGES.choose(&mut rng).unwrap();
                for _ in 0..count {
                    let cmd = COMMANDS.choose(&mut rng).unwrap();
                    thread::sleep(Duration::from_millis(rng.gen_range(25..200)));
                    pb.set_message(format!("{pkg}: {cmd}"));
                    pb.inc(1);
                }
                pb.finish_with_message("waiting...");
            })
        })
        .collect();

This block maps directly to yarnish.rs lines 34--36 at commit 0c037edc:

pb.set_style(spinner_style.clone());     // yarnish.rs line 34
pb.set_prefix(format!("[{}/?]", i + 1)); // yarnish.rs line 35
thread::spawn(move || { ... })           // yarnish.rs line 36

Ticker::drop_in_place at 0x4d3f95. This is dropping the Ticker handle from the previous loop iteration, not spawning a new thread. The Ticker is an indicatif-internal struct managing the spinner animation.

call core::ptr::drop_in_place...ss_bar..Ticker$GT$  ; 0x4d3f95 -- Verified

pb.set_style(spinner_style.clone()) (mapped to yarnish.rs line 34). ProgressStyle::clone produces the cloned style; set_style applies it to the ProgressBar:

call _$LT$indicatif::style::...Clone$GT$::clone     ; 0x4d3fbf -- Verified
                                                     ; = spinner_style.clone()
mov  rdi, qword [rsp+0x50 {var_288}]
mov  rsi, r12  {var_1d8}
call indicatif::progress_bar::...ressBar::set_style  ; 0x4d3fd1 -- Verified
                                                     ; = pb.set_style(...)

thread::spawn(move || { ... }) -- the spawn call:

This is the same encryption thread spawn as Step 9, not a separate ticker thread. The thread::spawn call chain is inlined; Thread::new at 0x4d3fe3 is its innermost call:

call std::sys_common::thread::min_stack              ; 0x4d3fd9 -- Verified
mov  r15, rax
xor  edi, edi                                        ; stack = 0 (default)
call std::thread::Thread::new                        ; 0x4d3fe3 -- Verified
                                                     ; = thread::spawn(move || {...})

ProgressStyle::clone + set_style before Thread::new does not indicate a separate enable_steady_tick ticker thread. It maps to pb.set_style(spinner_style.clone()) in yarnish.rs. There is only one encryption-related Thread::new call visible in the screenshots provided. Whether enable_steady_tick spawns a second thread cannot be confirmed without additional disassembly showing a second Thread::new call.

Arc::new and lock inc after Thread::new -- Verified as part of JoinInner construction (established from HLIL in Step 9):

call alloc::sync::Arc<T>::new        ; 0x4d4008 -- Verified
mov  qword [rsp+0x70 {var_268}], rax
lock inc qword [rax]                 ; 0x4d4012 -- Verified (Arc::clone)

Arc::clone confirmed from pasted alloc/src/sync.rs.

Step 11 —Mutex-protected Vec::push and loop back-edge

Address range: 0x4d39e0 -- 0x4d3a20

The Vec::push at 0x4d39ff does not occur in isolation, it is wrapped in a Mutex::lock guard:

004d39e0   call std::sync::mutex::Mutex<T>::lock    ; acquire Mutex<Vec<JoinHandle>>
004d39eb   mov  rsi, r12  {data_5e7080}
004d39ee   call core::result::Result::unwrap
004d39f3   mov  r13, rax                            ; r13 = MutexGuard
004d39ff   call alloc::vec::Vec::push               ; push JoinHandle under guard
004d3a0b   call core::ptr::drop_in_place(...)       ; drop MutexGuard (unlock)
004d3a10   mov  rax, qword [rsp+0x110 {var_1c8}]    ; IntoIter.start advance
004d3a18   mov  rcx, qword [rsp+0x118 {i_1}]        ; IntoIter.end
004d3a20   jmp  0x4d39b0                            ; loop back-edge

The Mutex is Akira's own code, not from the stdlib -- Verified from the pasted library/std/src/thread/scoped.rs. ScopeData contains no Mutex whatsoever:

pub(super) struct ScopeData {
    num_running_threads: AtomicUsize,
    a_thread_panicked: AtomicBool,
    main_thread: Thread,
}

The scope() function waits for all threads using park/unpark exclusively -(Verified from the pasted scoped.rs):

while scope.data.num_running_threads.load(Ordering::Acquire) != 0 {
    park();
}

Each finishing thread calls self.main_thread.unpark() via decrement_num_running_threads. There is no Mutex, no Condvar, and no Vec anywhere in the scoped thread stdlib machinery at db034cee. The Mutex at data_5e7080 therefore belongs to akiranew/src/lock.rs.

What the Mutex guards -- unknown. The Vec::push argument is var_d0 / r15 (binary-observed). Tracing r15 back to its assignment site has not been completed from the available screenshots. The guarded Vec could hold encrypted file paths, progress records, ransom note entries, or something else entirely. The earlier label Vec was wrong and is retracted -- scoped threads do not need a Mutex for handle management.

  • Mutex::lock : Source at library/std/src/sync/mutex.rs.
  • Vec::push: Source at library/alloc/src/vec/mod.rs.
  • MutexGuard drop: Verified (symbol binary-observed).
  • Loop back-edge — Verified (binary-observed). jmp 0x4d39b0 returns to the loop head from Step 4.
  • yarnish.rs comparison: No mutex appears in the yarnish.rs template at 0c037edc. The presence of a mutex-protected Vec::push in every loop iteration is an Akira-specific addition whose purpose requires further analysis of lock.rs.

More details about tracing back mutex can be found in the sectin of Further Reading below.

Akira deviation from yarnish.rs: The yarnish.rs template at commit 0c037edc pushes handles into a plain Vec<_> with no mutex:

}).collect();

Akira wraps the Vec in a Mutex, acquiring it before each push. This is binary-observed, no such mutex appears in yarnish.rs. The mutex at data_5e7080 is distinct from the one at data_5ebe00 seen in Step 7.

Step 11b — Real Thread::new: sys::unix::thread::Thread::new

Address range: 0x4d4062 -- 0x4d40d2

High Level IL

High Level IL

Disassembly

Disassembly

Graphi View

Graphi View

The graph view reveals the actual pthread_create-invoking Thread::new, which was not visible in the earlier screenshots.

num_running_threads and closure struct:

004d405e   add  rdi, 0x10
004d4062   call std::thread::scoped::Sco...um_running_threads  ; semaphore check

Immediately after the semaphore check, the closure capture struct is built:

004d406d   push 0x58 / pop rdi {0x58}              ; size = 88 bytes
004d406c   push 0x8  / pop rsi {0x8}               ; align = 8
004d406d   call alloc::alloc::exchange_malloc      ; allocate closure struct

Then 14 mov instructions fill the struct fields from stack variables (var_288, var_278, var_2b0, var_148_1, var_150, var_2a8 etc.) followed by two movups for the XMM key material, and at 0x4d40cb:

004d40cb   lea  rcx, [rel data_5e66a8]              ; vtable pointer

Then the actual spawn:

004d40d2   call std::sys::unix::thread::Thread::new::hd257ed4d46662134

This is a different mangled symbol from 0x4d3e7e (h977b0e8df4ff7524).

The symbol hd257ed4d46662134 resolves to std::sys::unix::thread::Thread::new, the PAL-level function in library/std/src/sys/pal/unix/thread.rs that calls pthread_create directly. This is imp::Thread::new in the spawn_unchecked_ closing lines (Verified from library/std/src/thread/mod.rs):

Ok(JoinInner {
    native: unsafe { imp::Thread::new(stack_size, main)? },
    thread: my_thread,
    packet: my_packet,
})

JoinInner is defined in library/std/src/thread/mod.rs:

struct JoinInner<'scope, T> {
    native: imp::Thread,
    thread: Thread,
    packet: Arc<Packet<'scope, T>>,
}

Thread at db034cee is pub struct Thread(Inner) where Inner is an enum with Other(Pin<Arc>)(Verified from library/std/src/thread/mod.rs).

Error path after Thread::new:

004d40f2   call core::ptr::drop_in_place...P$$RPS$GT$  ; drop Box<dyn FnOnce()>
004d40fc   call core::ptr::drop_in_place...Thread$GT$  ; drop partial Thread

This matches the verified sys/pal/unix/thread.rs source:

return if ret != 0 {
    drop(Box::from_raw(p));   // drop the double-boxed closure
    Err(io::Error::from_raw_os_error(ret))
} else {
    Ok(Thread { id: native })
};

The data_5e66a8 vtable pointer at 0x4d40cb is the Box<dyn FnOnce()> vtable, the call_once slot pointing to the main shim, as described in the report (Herzog, 2024, Figures 24--26).

Step 12 — Loop back-edge and IntoIter drop

Address range: 0x4d3a10 -- 0x4d3a22

mov  rax, qword [rsp+0x110 {var_1c8}]   ; load updated IntoIter.start
mov  rcx, qword [rsp+0x118 {i_1}]       ; load IntoIter.end
jmp  0x4d39b0                           ; back to loop head

When start == end, execution falls through to:

call _$LTalloc..vec..into_it...op..Drop$GT$::drop  ; 0x4d3a2a

The file list memory is freed. The Vec now contains one handle per file. A post-loop log call confirms completion before the join phase.

Step 13 — Join loop and Mutex cleanup

Address range: 0x4d3b40 -- 0x4d3b9f

call std::sync::mutex::Mutex<T>::lock    ; 0x4d3b43  (data_5e7028)
call core::result::Result::unwrap
// ...
call std::sync::mutex::Mutex<T>::lock    ; 0x4d3b73  (data_5e7040)
call core::result::Result::unwrap
call core::ptr::drop_in_place(...)       ; MutexGuard drop

The two static addresses data_5e7028 and data_5e7040 are now fully identified from .data.rel.ro. Binary Ninja's annotation shows both point into the log format vtable for "File lock started: , thread id: ...", these are not user-visible mutex objects but the Debug formatter vtables for the log call arguments. The actual join loop (pthread_join via Thread::join) follows:

call Thread::join  →  libc::pthread_join(id, ptr::null_mut())

This corresponds to yarnish.rs:

for h in handles { let _ = h.join(); }

Reconstructed source

Combining all steps, the reconstructed lock function is:

fn lock(
    files: Vec<PathBuf>,
    thread_limit: u64,
    exclude: *const u8,
    opts: *const OperatorOptions,
) {
    // Step 2: ProgressStyle
    let spinner_style = ProgressStyle::with_template("...").unwrap()
        .tick_chars("...");

    // Step 3: MultiProgress
    let m = MultiProgress::new();

    // Step 7: scoped thread scope with semaphore
    std::thread::scope(|scope| {
        // Step 4: iterate files (NOT (0..n) as in yarnish.rs)
        let handles: Vec<_> = files.into_iter().map(|path| {

            // Step 5: per-file seed (Akira-specific)
            let now = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap();
            let pid = unsafe { libc::getpid() };
            let key = akira_v2::prng::HashStruct::hash(now, pid);
            // key = 64 bytes stored in closure

            // Step 6: ProgressBar with file path as prefix
            let pb = m.add(ProgressBar::new(...));
            pb.set_style(spinner_style.clone());  // Step 10: yarnish.rs line 34
            pb.set_prefix(path.display().to_string());  // file path, not "[i/?]"
            // Note: enable_steady_tick presence unconfirmed from screenshots

            // Step 7: wait for semaphore slot
            while scope.num_running_threads() >= thread_limit as usize {}

            // Step 9/10: spawn encryption thread
            scope.spawn(move || {
                log::info!("File lock started: , thread id: {}", ...);
                lock_closure(path, key, pb, ...);
            })

        }).collect();  // Step 11: Mutex<Vec<??>> -- contents unknown

        // Step 13: join all
        for h in handles { h.join().unwrap(); }
    });
}

Section 7: lock_closure and Encryption

lock (user code, inlined)
  → Builder::spawn_unchecked_ (inlined)
    → main shim (inlined)
      → catch_unwind (inlined)
        → __rust_begin_short_backtrace (NOT inlined -- #[inline(never)])
          → lock_closure (the encryption function)

This is the symmetric key generated by random number (ThreadRng).

As identifed in CheckPoint report, Akirav2 malware author used dalek.rs- curve25519 to implement “a high-speed, secure elliptic curve used primarily for ECDH key exchange (X25519) and digital signatures (Ed25519), offering 128-bit security with 256-bit keys” (source).

Ransom note

Ransom note

Correction (24 June 2026). An earlier version of this section described the symmetric primitive as ChaCha20 and the key-wrapping as RSA. That is the scheme used by the older C++ Akira lineage, not the Rust v2 ESXi encryptor analysed here. The Rust v2 pipeline is X25519 (curve25519-dalek 4.1.1) for asymmetric key wrapping and SOSEMANUK (via rust-crypto 0.2.36) for symmetric per-file encryption, with the session key drawn from a ChaCha12 CSPRNG (rand 0.8.5 / rand_chacha 0.3.1, surfaced as thread_rng / ThreadRng). I independently confirmed SOSEMANUK in my own static recovery: the FSM multiplier 0x54655307 with a rotate-left-7 appears 20 times in the HLIL/SSA, the Serpent24 key-schedule constant 0x9E3779B9 is present, and the literal string “sosemanuk” survives in .rodata. Crate versions come from my 29-crate recovery; algorithm roles are corroborated by Check Point. Apologies for the earlier conflation.

The actual encryption logic lives in lock_closure. Key points from Check Point's analysis:

  • File targeting: the closure walks the filesystem, filtering by extension. ESXi-relevant extensions (.vmdk, .vmx, .vmsn, .nvram and others) are the primary targets.
  • Symmetric encryption: a per-file session key is generated per run, and the file payload is encrypted with SOSEMANUK, a software-oriented stream cipher chosen (like other stream ciphers) for throughput on targets without hardware AES acceleration. The implementation comes from the legacy rust-crypto 0.2.36 crate (DaGenix), where SOSEMANUK is heavily inlined and must be recovered from its characteristic constants rather than a named import. In this sample the fingerprints are unambiguous: the FSM multiplier 0x54655307 followed by a rotate-left-7 (the SOSEMANUK FSM update, recovered 20 times in the HLIL/SSA), the Serpent key-schedule constant 0x9E3779B9, and the residual string “sosemanuk” in .rodata.
  • Randomness: the session key is drawn from ThreadRng (thread_rng), which in rand 0.8.5 is a ReseedingRng<ChaCha12Core, OsRng>. In other words the CSPRNG is ChaCha12 from rand_chacha 0.3.1, reseeded from the OS via getrandom 0.2.10. This is the only place ChaCha appears in the Rust v2 binary: as the RNG that produces key material, not as the file cipher. (See Part 2 for the thread_rng recovery.)
  • Key protection: the session key is wrapped with a per-binary hardcoded X25519 (curve25519) public key, the dalek curve25519-dalek 4.1.1 stack, not RSA. The public key is embedded in the binary and written alongside the encrypted files; only the operator’s matching private key can recover the session key, enabling decryption upon ransom payment.

Further Reading

1. Why UAWAVAUATSH exists

Source: https://github.com/torvalds/linux/blob/v6.11/arch/x86/lib/x86-opcode-map.txt

It is Binary Ninja’s hex view ASCII column rendering the raw machine code bytes of the HumanDuration::fmt function prologue. There is no string UAWAVAUATSH anywhere in the ELF.

The encoding mechanics

The opcode map file is stable across kernel versions for x86–64, the encodings do not change. But I pin v6.11 rather than master, for the same reason the Rust stdlib commit pinned here: reproducibility.

The Linux kernel opcode map (canonical reference for x86 instruction encoding) lists: 0x55 = PUSH rBP/r13, 0x56 = PUSH rSI/r14, 0x57 = PUSH rDI/r15, 0x54 = PUSH rSP/r12 (GitHub).

The REX prefix range (0x40–0x4f)

In 64-bit mode (o64), opcodes 0x40–0x4f are REX prefix bytes, not instructions. Specifically: 0x41 = REX.B, 0x48 = REX.W (github, OSDev Wiki).

In 32-bit mode those same bytes were single-byte INC/DEC instructions, but in 64-bit mode they are all repurposed as REX prefix encodings.

  
top row:    INC eAX   INC eCX   INC eDX  ...   (i64)
bottom row: REX       REX.B     REX.X    ...   (o64)

mode        0x41 means
  --------    ----------
  32-bit      INC eCX        (top row, superscript i64 = illegal in 64-bit)
  64-bit      REX.B prefix   (bottom row, superscript o64 = only in 64-bit)

Intel SDM Table A-2, row 4

Intel SDM Table A-2, row 4

INC(i64) general register / REX(o64) Prefixes

  40      41      42      43      44      45      46      47
  eAX     eCX     eDX     eBX     eSP     eBP     eSI     eDI
  REX     REX.B   REX.X   REX.XB  REX.R   REX.RB  REX.RX  REX.RXB

The two superscripts do all the work:

  • i64 = invalid in 64-bit mode: the INC eAX meaning of 0x40 is illegal in 64-bit mode
  • o64 = only in 64-bit mode: the REX meaning of 0x40 only exists in 64-bit mode

And row 5 shows the PUSH encoding you need:

PUSH(d64) general register

  50      51      52      53      54      55      56      57
  rAX/r8  rCX/r9  rDX/r10 rBX/r11 rSP/r12 rBP/r13 rSI/r14 rDI/r15

The /r8–/r15 disambiguation in row 5 is exactly what REX.B from row 4 provides. 0x55 alone = push rbp. 0x41 0x55 = REX.B prefix + 0x55 = push r13. Same opcode byte, extended register selected by the preceding REX.B.

This is the primary source. The Linux v6.11 opcode map entry 41: INC eCX (i64) | REX.B (o64) is a direct transcription of this SDM table. Both sources agree, and the SDM is the ground truth since it is the processor specification.

So the connection to UAWAVAUATSH is:

akirav2.elf is a 64-bit ELF
    → CPU is in 64-bit long mode
    → 0x41 = REX.B  (not INC eCX)
    → 0x41 0x57 = REX.B + PUSH rDI/r15 = push r15
    → Binary Ninja renders 0x41 as 'A', 0x57 as 'W'
    → hence "AW" in the ASCII column

2. REX — Register Extension.

REX stands for Register Extension. It is a one-byte prefix introduced in x86–64 to extend the instruction set beyond what the original 32-bit encoding could address.

The byte has the fixed high nibble 0100 (hence 0x4_), with the low nibble encoding four flag bits:

0100 W R X B
     │ │ │ └─ REX.B — extends the r/m field, SIB base, or opcode reg field
     │ │ └─── REX.X — extends the SIB index field
     │ └───── REX.R — extends the ModRM reg field
     └─────── REX.W — promotes operand size to 64 bits

Why it was needed

The original x86 encoding only had 3 bits to identify a register, giving eight registers (rax–rdi). x86-64 added eight more (r8–r15). Rather than redesigning the entire encoding, Intel and AMD added the REX prefix to supply a fourth bit for each register field, doubling the addressable register count.

The four specific bits

REX.W (0x48) — use 64-bit operand size instead of the default 32-bit. Most mov, add, sub etc. instructions need this when operating on 64-bit values.

REX.B (0x41) — extends the register encoded in the opcode byte itself (e.g. the low 3 bits of a push/pop opcode), or the r/m field of ModRM. This is how 0x41 0x57 becomes push r15 instead of push rdi — the B bit flips the register selection from the base-8 register to the extended one.

REX.R (0x44) — extends the reg field of the ModRM byte.

REX.X (0x42) — extends the index field of the SIB byte.

Why 0x41 renders as A in ASCII

0x41 is the ASCII code for A. Since REX prefixes occupy 0x40–0x4f, and that range maps directly onto uppercase Latin letters @ABCDEFGHIJKLMNO, every REX.B prefix byte in a hex viewer renders as A. This is purely coincidental — the x86-64 architects chose that range because it freed up the old INC/DEC opcodes in 64-bit mode, not because of any ASCII consideration.

When pushing extended registers, 0x41 precedes the base push opcode. For example, to push r9 the encoding is 0x41 followed by 0x50 + (9 - 8) = 0x51. The original eight registers push with a single byte; extended registers require two bytes: the REX.B prefix plus the base opcode (Eklitzke).

Applying this to UAWAVAUATSH:

BytesASCIIInstruction0x55Upush rbp0x41 0x57AWpush r15 (REX.B + 0x57)0x41 0x56AVpush r14 (REX.B + 0x56)0x41 0x55AUpush r13 (REX.B + 0x55)0x41 0x54ATpush r12 (REX.B + 0x54)0x53Spush rbx0x48HREX.W prefix (start of next instruction, e.g. sub rsp, N)

All eleven bytes fall in the printable ASCII range (0x41–0x57 are uppercase letters, 0x53–0x55 likewise, 0x48 is H), so Binary Ninja's hex view renders all of them as characters rather than dots.

Why this specific prologue

Compilers save callee-preserved registers at function entry when the function needs to use them. The System V AMD64 ABI designates rbx, rbp, r12–r15 as callee-saved. A function that uses all six must push all six on entry. The HumanDuration::fmt function is complex enough (formatting a duration string requires multiple branches and temporaries) that LLVM assigned all six callee-saved registers, producing exactly this push sequence (Eklitzke).

Why 0x41 renders as A

0x41 is the ASCII code for uppercase A. The REX prefix range 0x40–0x4F was chosen by the x86-64 architects precisely because those opcodes were the legacy single-byte INC/DEC instructions, which were retired in 64-bit mode to free the space. They happen to sit in the middle of the uppercase ASCII Latin alphabet. So every REX-prefixed push of an extended register produces an A in the ASCII column followed by the push target letter (OSDev Wiki).

The byte sequence is entirely mechanical and deterministic. Any function in any x86–64 ELF that saves rbp, r15, r14, r13, r12, rbx in that order will produce UAWAVAUATSH in a hex viewer.

3. Mutex

Looking back to (0x4d3c2b--0x4d3c63) we saw:

004d3c2b   cmp  qword [rsp+0x48 {var_290}], 0x0
004d3c31   lea  r13, [rsp+0x50 {var_288}]
004d3c36   je   0x4d4153

And the three lock inc atomics immediately before SystemTime::now. The je 0x4d4153 target was never shown in the screenshots, that is where control goes when the count is zero.

From the figure below, we also see std::thread::scoped::Sco...um_running_threads at 0x4d4062.

From thread/mod.rs at db034cee, the exact lines in spawn_unchecked_:

if let Some(scope_data) = &my_packet.scope {
    scope_data.increment_num_running_threads();
}

From scoped.rs at db034cee, increment_num_running_threads is:

pub(super) fn increment_num_running_threads(&self) {
    if self.num_running_threads.fetch_add(1, Ordering::Relaxed) > usize::MAX / 2 {
        self.overflow();
    }
}

And from thread/mod.rs, Packet contains:

struct Packet<'scope, T> {
    scope: Option<Arc<scoped::ScopeData>>,
    ...
}

ScopeData is in scoped.rs. From the earlier session search result, ScopeData contains:

pub(super) struct ScopeData {
    num_running_threads: Atomic<usize>,
    a_thread_panicked:   Atomic<bool>,
    main_thread:         Thread,
}

And from the scoped.rs source at the pinned commit, scope() blocks until num_running_threads reaches zero using a Condvar:

https://github.com/rust-lang/rust/blob/79e9716c980570bfd1f666e3b16ac583f0168962/library/std/src/thread/scoped.rs

The connection is:

Mutex + Condvar is the standard Rust pattern for scope() to wait for all spawned threads to finish. The Condvar is used inside ScopeData to park the scope-owning thread until num_running_threads drops to zero. The Mutex guards the condition variable's predicate check.

In the disassembly, the Mutex::lock at 0x4d3b40/0x4d3b73 on data_5e7028 and data_5e7040, and the Mutex::lock at 0x4d39e0 on data_5e7080 are the Condvar-associated mutexes inside ScopeData. This is the scope() call blocking after all threads are spawned, waiting for them to complete via decrement_num_running_threads calling notify_all on the Condvar.

So the connection is:

std::thread::scope(|s| {
    for path in files {
        s.spawn(|| lock_closure(path, ...));
    }
    // scope() blocks here on Condvar::wait_while(Mutex, num_running == 0)
})

The Mutex and Condvar pair are from scoped.rs , not Akira's own code.

The Vec::push at 0x4d39ff question therefore changes: it may be pushing into a Vec inside ScopeData or a related structure managed by the scope machinery, not a user-visible Vec.

References

x86-psABIs project (2024) System V application binary interface: AMD64 architecture processor supplement, version 1.0. GitLab. Available at: https://gitlab.com/x86-psABIs/x86-64-ABI (PDF: https://github.com/hjl-tools/x86-psABI/wiki/x86-64-psABI-1.0.pdf)

Herzog, B. (2024) Inside Akira ransomware’s Rust experiment. Check Point Research. Available at: https://research.checkpoint.com/2024/inside-akira-ransomwares-rust-experiment/

Klabnik, S. and Nichols, C. (2019) The Rust programming language. San Francisco: No Starch Press.

RalfJung (2024) PathBuf incorrectly transmutes OsString to Vec — issue #124409. GitHub. Available at: https://github.com/rust-lang/rust/issues/124409

RalfJung (2024) PathBuf: replace transmuting by accessor functions — PR #124410. GitHub. Available at: https://github.com/rust-lang/rust/pull/124410

The Linux man-pages project (2024) getpid(2). Available at: https://man7.org/linux/man-pages/man2/getpid.2.html

Tompkins, W. et al. (2024) Akira ransomware continues to evolve. Cisco Talos Intelligence. Available at: https://blog.talosintelligence.com/akira-ransomware-continues-to-evolve/

console-rs (2024) indicatif 0.17.7 progress_bar.rs source. Available at: https://github.com/console-rs/indicatif/blob/0c037edc86449d84aa457d7d5db80b4166c18d6b/src/progress_bar.rs

console-rs (2024) indicatif 0.17.7 style.rs source. Available at: https://github.com/console-rs/indicatif/blob/0c037edc86449d84aa457d7d5db80b4166c18d6b/src/style.rs

console-rs (2024) indicatif 0.17.7. crates.io. Available at: https://crates.io/crates/indicatif/0.17.7

console-rs (2024) indicatif yarnish.rs example, commit 0c037edc86449d84aa457d7d5db80b4166c18d6b. GitHub. Available at: https://github.com/console-rs/indicatif/blob/0c037edc86449d84aa457d7d5db80b4166c18d6b/examples/yarnish.rs#L29

Xiao, C. (2025) Reconstructing Rust types. RE//verse 2025. Available at: https://recon.cx/2025/schedule/events/reverse.html

UAWAVAUATSH

Thanks for reading

Thanks for reading