arshi.py V2: Reverse the Verifier, Not the Hashes

arshi.py V2: Reverse the Verifier, Not the Hashes

write-upreverse-engineeringpythoncrackmevirtual-machine

arshi.py is a 4,783,326-byte Python crackme. It asks for a password, but most of the file is there to make that check hard to read. We'll start with the outer source, recover the code it hides, and simplify that verifier until we can work backward to a password.

You can find the original challenge on crackmes.one.

1. Quick intro

Run it and try an obviously wrong password:

C:\challenge>python -I -X utf8 arshi.py
[arshi.py V2] Passphrase: wrong
Access Denied

That gives us the behavior we're trying to change with a valid input. Rather than guess more passwords or replace the rejection message, let's open arshi.py and find out how it checks the answer.

2. Get to the hidden Python

Start with the names

The first thing to notice in the source is how the names are written. Its first function, on line 3, starts with this signature:

def 𝕭𝙈𝘿ᵤᴵ𝐽𝙽𝑱(𝐳𝘜𝚆z𝒃𝖚𝘒𝖮, 𝑡𝓆𝑃𝒚𝑻𝐈𝚂𝙒):

The body uses more mixtures of Unicode letter styles. We don't need to match those spellings by eye: Python applies NFKC normalization to identifiers, so its abstract syntax tree (AST) gives us consistent names. Try parsing the file and inspecting that same function:

import ast
from pathlib import Path

source = Path("arshi.py").read_bytes()
tree = ast.parse(source)          # Parse source; do not execute it.
functions = {
    node.name: node
    for node in tree.body
    if isinstance(node, ast.FunctionDef)
}

print(len(functions))             # 17 top-level functions
print(functions["BMDuIJNJ"].name)  # BMDuIJNJ: the signature above, normalized

Now look inside the functions, not just at their names. Sixteen have the same basic ingredients: encoded string chunks, offsets and a key, then a while loop whose integer state selects an if/elif branch. Each visited branch decodes a chunk and chooses the next state. This is flattened control flow, not sixteen independent password checks.

The remaining function, NcoNWktQ, is called at the end of the file. Its ''.join(...) expression calls those sixteen decoders in a particular order. Follow each decoder's state transitions, then join its output in that entry-point order. That reconstructs the encoded stream without running the source.

While following those branches, evaluate their constant prefix conditions too. Across the sixteen decoders, 255 are false and one is true. The true branch preserves a 60-character prefix used by the source-integrity check. An early extractor dropped it along with the dead branches, shifting the stream and breaking the record framing. The lesson is to evaluate the conditions, not assume every noisy-looking prefix is junk.

The entry function also constructs and shuffles an 85-character alphabet. Use each character's position in that recovered alphabet as alphabet_index, then decode each five-character group in little-endian order:

Here LE32 means a four-byte, little-endian encoding:

word = sum(alphabet_index[group[j]] * 85**j for j in 0..4)
append LE32(word mod 2**32)

The first 60 characters form a separate header; the rest decode to a body masked by a 64-bit word keystream generated by the entry code.

After XORing off that keystream, the body starts with a four-byte record count. Each record then has a four-byte length followed by its data, padded to a four-byte boundary. Following that framing gives 15 records.

To make sense of their contents, inspect the entry code that consumes them. It defines a custom sponge function with a 512-bit state, a 256-bit rate, and a 14-round permutation. We'll write it as H(message, domain, length): domain is an integer tag separating different uses, and length is the number of output bytes. Its constants come from the decoded data and its arithmetic from the entry code; substituting a standard hash would give different bytes.

In particular, record 14 is encrypted loader source, and record 13 supplies its key. The entry code's decryption is an XOR with the sponge output. In pseudocode:

loader = record[14] XOR H(record[13], 12131663077210646873,
                         len(record[14]))

This produces a 7,362-byte loader that parses as Python. We've reached the next layer rather than just another opaque byte string.

The entry code also fingerprints the original source after replacing that 60-character marker with zero bytes. The fingerprint contributes key material used later, so keep the original file unchanged and replace the marker only in a separate byte array. Don't fingerprint ast.unparse(tree): the AST helps us read the names, but this check depends on the original file bytes.

Catch the code as it's compiled

Reading the recovered loader explains what comes next. It selects 63 encrypted Python fragments in a fixed order, decrypts and compiles each one, and gives them a shared dictionary named _C. That dictionary lets one fragment leave state for the next. The loader also updates the fragment key using the current plaintext and feedback left by earlier fragments.

With descriptive field names substituted for the dictionary's numeric keys, the loop looks like this pseudocode:

context = initial_context(...)
for i, record_index in enumerate(fragment_order):   # 63 entries
    source = decrypt(fragment_records[record_index],
                     context.fragment_key, fragment_domain + i)
    if i < 62:
        context.fragment_key = update_key(context.fragment_key,
                                          source, context.feedback, i)
    execute_python(source, globals={"_C": context})

Some early fragments can be decrypted statically. Continuing that way would mean reproducing the state changes needed for every subsequent key. There's a more direct observation point: execute_python has to hand the plaintext to CPython's compiler before the fragment runs.

On Windows x64, the source-string pointer arrives in RCX at Py_CompileStringObject. A second breakpoint, on PyEval_EvalCode, gives us the globals dictionary in RDX; its _C entry leads to the shared state we just found in the loader. Native breakpoints let us inspect those values without installing a Python tracing hook.

Here's the capture idea in pseudocode:

on entry to Py_CompileStringObject:
    save read_c_string(RCX) as inert source text

on entry to PyEval_EvalCode:
    globals = read_cpython_dictionary(RDX)
    record the relevant state, seed, and key material from globals["_C"]

These captures used x64 CPython 3.14.3. Check the object layouts if you use a different build.

Empty input was enough for the analysis capture: the program decrypted every verifier fragment before rejecting it. That gave us the loader plus all 63 fragments, 1,094,393 bytes of source. The loader and first three fragments matched the independent static extraction byte-for-byte.

The fragments also reveal why environment state matters: source-integrity and anti-analysis checks feed values back into the keys. Below, clean feedback means the values for the checks' expected path, not an arbitrary replacement value. The captured timing/function-check updates matched that arithmetic model.

The _C snapshots give us concrete inputs for the next steps:

EntryWhat we take from it
_C[4]Source-dependent key material from the outer fingerprint check
_C[5]Environment-feedback bytes used in key derivation
_C[6]The 64-bit seed used by the ALU handlers
_C[8]Rolling state updated as the verifier progresses
_C[11]The list of 48-byte states before and after the rounds
_C[14]The current fragment key

Together with the recovered source, round programs and tables, these snapshots let us check a simplified model against real inputs and outputs—not just hope our algebra is right.

Explanatory animation, not a recorded execution trace. Open the source-recovery video.

3. Simplify the VM

Eight operations underneath the wrappers

Among the recovered fragments are VM interpreters: they execute encoded round programs rather than expressing the password transformation as ordinary Python. Their arithmetic-logic unit (ALU) dispatches to 128 handlers. Before reversing whole programs, inspect what those handlers actually do to their two 64-bit operands, a and b.

Much of that work is wrapping and unwrapping values: affine maps (multiply and add modulo 2**64) and cubic polynomials surround mixed Boolean/arithmetic expressions. Simplifying those expressions reduces handler i to:

x = a XOR seed XOR 0x95f100606e976bd2
y = b XOR rol64(seed, 19) XOR 0x030374bb5e94af88
result = operation[i mod 8](x, y)
         XOR rol64(seed, 37) XOR 0xd2ed7a52be200c0d

The eight operations are XOR, addition, subtraction, AND, OR, multiplication, rotate-left, and rotate-right. Arithmetic wraps modulo 2**64, and rotation counts are reduced modulo 64.

The seed isn't something we have to guess. The captured _C[6] value is 0x95f100606e976bd2. Substituting it into the handler formula makes all three masks disappear:

MASK = (1 << 64) - 1

def rol64(x, n):
    n &= 63
    return ((x << n) | (x >> ((-n) & 63))) & MASK

seed = 0x95f100606e976bd2
assert seed ^ 0x95f100606e976bd2 == 0
assert rol64(seed, 19) ^ 0x030374bb5e94af88 == 0
assert rol64(seed, 37) ^ 0xd2ed7a52be200c0d == 0

To reverse encoded values inside the round programs, we also need the inverse of those wrappers. The cubic part has the form:

P(x) = x + q*x² + c*x³  (mod 2**64)

Here q and c are even, so the derivative is odd. Start with the correct lowest bit and use Newton/Hensel lifting to double the precision each time. Six steps give us all 64 bits:

def invert_cubic(y, q, c):
    x = y & 1
    for _ in range(6):
        error = (x + q*x*x + c*x*x*x - y) & MASK
        derivative = (1 + 2*q*x + 3*c*x*x) & MASK
        x = (x - error * pow(derivative, -1, 1 << 64)) & MASK
    assert (x + q*x*x + c*x*x*x) & MASK == y & MASK
    return x

For an affine map y = m*x + b, undo the addition and multiply by the modular inverse of m; the multiplier is odd, so that inverse exists modulo 2**64. We can now use ordinary word operations and explicit encoding inverses instead of treating 128 decorated handlers as unrelated functions.

Reverse the round functions

The later fragments append 32 round outputs to _C[11]. Starting from its initial state, that gives us S0, S1, ..., S32. Each state is 48 bytes, or six little-endian 64-bit words.

For each round, its fragment selects a program, a table and one of eight VM families. The family determines the instruction and operand layout, so one decoder won't work for every program.

That fragment also tells us how to derive its 48-byte round key. Concatenate the captured fragment key, feedback and source-dependent key material with the rolling value updated by that round. Hash them using the domain literal in the round's source. In pseudocode:

round_key = H(concat(fragment_key, feedback, source_key,
                     little_endian_8(updated_rolling)),
              round_domain, 48)

Now we have each round's program, table, key and observed input/output. Decode its instructions, strip off the ALU wrappers, and use the following inverse recipe:

VM familyHow to reverse it
StackReverse modular powers, word matrices, and nibble-based Feistel rounds
RegisterInvert GF(2^64) arithmetic and the 384-bit binary matrix; reverse the surrounding Boolean-circuit Feistel rounds
HistoryReverse versioned stores, xorshifts, and repeated-variable modular equations
CoroutineUndo the lane schedule and keyed Feistel-like updates in reverse order
Prime fieldInvert shifted fifth powers and 3×3 matrices modulo 2**64 − 59
ProjectiveInvert MĂśbius maps and word mixing; handle the point at infinity explicitly
CRTInvert the residue maps and recombine; preserve the original branch for values outside the prime-product range
SubstitutionBuild inverse 16-bit tables and reverse the alternating cyclic updates

For example, the Feistel rows don't require us to invert their round function F. The surrounding update is what makes them reversible. Here's the ordinary XOR-Feistel identity, shown schematically:

forward:  new_left  = right
          new_right = left XOR F(right, key)

reverse:  right = new_left
          left  = new_right XOR F(new_left, key)

The history machine needs a different precaution: one expression can contain the same unknown several times. An early model treated those as simple unary inverses; checked modular root lifting handles the polynomial cases instead.

We already have a test pair for every round from the _C[11] snapshots. Run each model forward on its captured input, then backward on the captured output. All 32 forward outputs and inverse inputs matched. That gives us working inverses; the missing piece is which final state to reverse.

4. Follow the hashes

To find that final state, read the fragments after the last VM round. They compute hash comparisons, update an accumulator and a rolling value, and use those results to unmask a stored 48-byte target. The difference between S32 and that target—the residual—then goes into the key for encrypted success code.

The comparison code is spread over thirteen fragments, each containing eight stored 16-byte digests: 104 comparisons in total. Each one compares a stored digest with H(S0, domain, 16). Notice that they all hash the initial state, not successive VM outputs.

Looking at what consumes the comparison result is the important part. A match or mismatch selects a constant for the accumulator; it doesn't immediately accept or reject the password. We'll call that sequence of choices the hash-gate path. In the overview below, hash_gate_path returns the resulting accumulator, rolling value and match count.

Here's an overview in pseudocode. The helper names describe those jobs; they aren't original function names. S0 is the input's initial 48-byte encoding, whose packing we'll undo in step 5:

S0 = normalize_and_mask(input, feedback)
states = [S0]
for round in rounds:
    states.append(round.forward(states[-1]))

accumulator, rolling, matches = hash_gate_path(S0)
target = unmask_final_target(accumulator, rolling, fragment_key, feedback)
residual = states[32] XOR target
key = derive_payload_key(states, residual, feedback)
payload = authenticate_and_decrypt(key)

Each comparison uses a different domain tag:

13153994688848919583 XOR ((group << 4) | entry)
group = 0..12, entry = 0..7

Each group stores both match and mismatch constants and has its own addend literal. The accumulator starts at zero; the rolling value starts at the value captured after the VM rounds. The updates are, in pseudocode:

choice = match_constant if comparison_matched else mismatch_constant
accumulator = ((accumulator XOR choice) * 13787848793156543929
               + group_addend + entry) mod 2**64

# After the group's eight entries:
rolling = ((rolling XOR accumulator XOR group_addend)
           * 10723151780598845931 + 29) mod 2**64

Once we propose the comparison outcomes, these updates need no guessed password bytes: they just consume the selected constants. That's what makes testing a hash-gate path possible.

The program updates the rolling value after each group. In the last verifier fragment, a stored 48-byte literal—call it W—is XORed with a sponge output to produce the target. Here LE64 encodes a word as eight little-endian bytes, and || means byte concatenation:

target = W XOR H(LE64(accumulator) || LE64(rolling)
                 || fragment_key || feedback,
                 5581781327422661441, 48)

The fragment key and feedback come from the context captured before that last fragment. The domain integer is a literal in its source, not a password guess.

The last fragment combines the target with S32 through six ALU calls. Under the captured seed, all six are XORs, so they give residual = S32 XOR target. There still isn't a simple “residual is zero, therefore win” branch: these bytes feed the payload key. Trying S32 = target, and therefore a zero residual, is a hypothesis we must check through decryption and authentication.

That leaves a circular-looking problem: the hash choices determine the target, but their comparisons use S0, which we want to recover by reversing from that target. We can resolve it by testing a proposed path for self-consistency.

The tempting proposal is “all 104 hashes match.” But reversing that target and rehashing the resulting initial state matched zero digests. The proposal contradicted its own result. Chasing 104 preimages was the wrong problem.

The empty-input capture offers a better proposal: every comparison in that run took its mismatch side. Compute the accumulator and rolling value for those choices—our chosen_accumulator and chosen_rolling—and use them to unmask a candidate final target. We can reverse from it, then check whether the resulting S0 really takes the same path. We aren't claiming that mismatch alone means success.

Reasoning animation, not the program's control-flow graph. Open the hash-detour video.

5. Work backward to the password

Use that all-mismatch target as the proposed S32, then apply the per-family inverses from step 3 in reverse round order:

states[32] = target
for i from 31 down to 0:
    states[i] = inverse(round[i], states[i + 1])
    assert forward(round[i], states[i]) == states[i + 1]

accumulator, rolling, matches = hash_gate_path(states[0])
assert matches == 0
assert (accumulator, rolling) == (chosen_accumulator, chosen_rolling)

Those last checks close the loop: the state recovered from our proposed target must recreate the hash choices and values used to construct it. Next, turn that state back into an input.

The input-preparation fragment tells us the short-input format. For at most 47 bytes, it pads the input to 47 bytes, appends one length byte, and XORs the resulting 48 bytes with a feedback-derived mask:

S0 = (input || zero-padding-to-47 || byte(length))
     XOR H(feedback, 13226596740533428535, 48)

Longer inputs have an extra rolling-fold step; try the short-input format first and let its length/padding checks tell us whether it fits. Remove the mask using the feedback captured earlier:

normalized = S0 XOR H(feedback, 13226596740533428535, 48)
length = normalized[47]
assert length <= 47
assert normalized[length:47] is all zeroes
answer = normalized[:length]

This gives us 45 ASCII bytes, two zero-padding bytes, and length byte 45. Rechecking the recovered state still gives zero matching digests out of 104. That's consistent, though it isn't enough on its own—arbitrary inputs also miss the hashes.

Here's the candidate:

Even-Encryption-Cannot-Hide-My-Love-For-Arshi

No input file needed. We'll paste it at the prompt for the final check.

6. Check the decrypted code

We still need to check the second hypothesis: that a zero residual gives the right decryption key. The loader places the encrypted success code from record 12 into the shared context; the last verifier fragment derives its key and checks an authentication tag before executing it. This is why replacing a rejection message wouldn't recover the actual success code.

Reading that final fragment shows that the key depends on all 33 states (S0 through S32), the 48-byte residual, and environment feedback. For our proposed target, the residual is zero.

Its key derivation includes 131,072 memory-fill iterations and 131,072 data-dependent mixing iterations. Reproduce that computation with the recovered states, then compare the authentication result with the verifier's stored tag:

assert residual == 48 zero bytes
key = derive_key(states[0..32], residual, feedback)
assert authenticate(key, encrypted_payload) == stored_authenticator
plain = decrypt_payload(key, encrypted_payload)
parse plain as Python source without executing it

The tag matches, and the decrypted 2,718 bytes parse as Python. This checks the key derivation and the zero-residual hypothesis, rather than merely telling us the recovered password looks plausible. Acceptance by the unchanged program is the remaining check.

Reading that plaintext reveals an optional ARSHI_EGG=1 environment switch, which prints an extra Unicode line after successful decryption. It's a display option in the recovered success code, not a second password to guess.

What makes it tricky

The source check and anti-analysis state feed real keys, and the encrypted fragments are chained together. The final authentication also means that patching a verdict isn't enough to recover the success code.

What gives us a way through is simpler: CPython has to see the plaintext, the clean seed cancels the ALU masks, and the rounds are reversible. The hash comparisons are a distraction; for this accepted input, every one of them misses. We don't need to break the custom sponge.

Final check

Run the unchanged script and paste the candidate at the prompt. Set ARSHI_EGG=1 to enable the optional extra message.

The unchanged program accepts Even-Encryption-Cannot-Hide-My-Love-For-Arshi and prints Access Granted. With ARSHI_EGG=1, the recovered success code also prints its extra Unicode message.

The colors are the program's own ANSI output, rendered with ANSI handling enabled.

Console screenshot showing the recovered passphrase, Access Granted, and the optional Unicode message.

Get notified when new articles are published.

Stay in the loop