Client-Side Password Validation Explained: When Your Password Checker Builds Its Own Tiny CPU

πŸ‡ΊπŸ‡ΈENπŸ‡ΈπŸ‡¦AR

Imagine a developer who is worried that someone might read the password validation code.

So they encrypt the validation functions, decrypt them at runtime, put them into freshly mapped memory, add a custom virtual machine, shuffle the password characters around, and throw some arithmetic on top.

At this point the code looks intimidating enough to deserve its own movie trailer.

Then you remember one small problem: the client still has to run the checker.

That means the machine still has to reveal the instructions, the values being compared, and the rules used to produce the final decision. Obfuscation can make reversing slower. It does not magically turn a client into a trusted oracle.

This article walks through a generic example of a heavily obfuscated password validator, from the first ptrace() call to the custom VM, runtime decryption, dynamic tracing, and finally the much less exciting mathematics that actually decides whether the password is correct.


What Is Client-Side Password Validation, Anyway?

A client-side password validator is any scheme where the client itself decides whether an entered secret is correct.

That can be as simple as:

if password == "correct-horse-battery-staple":
    print("Correct!")

or as dramatic as:

Encrypted code
     |
     v
Runtime decryption
     |
     v
Executable memory
     |
     v
Custom VM
     |
     v
Password permutation
     |
     v
Arithmetic
     |
     v
Comparison

The second version looks much harder to reverse.

It is.

It is also still a client.

If the answer depends on a secret value embedded in the client, an analyst who controls the client can eventually inspect the same information the client uses.

That is the core problem.

Obfuscation vs. Security

Obfuscation is useful when the goal is to increase attacker effort.

It is not a replacement for a security boundary.

A good way to think about it is putting a combination lock inside a glass box. The box can have smoke machines, three warning labels, and a laser turret. The combination is still physically sitting inside the box.


Why Should You Care?

This design shows up whenever developers want to hide a verification secret from casual inspection.

Common cases include:

  • Desktop license checks
  • Client-side activation systems
  • Game key validation
  • Reverse engineering challenges
  • Proprietary protocol clients
  • Offline feature gates
  • Mobile applications with embedded logic

The security impact depends on what is being protected.

DesignMain risk
Plaintext comparisonSecret recovery is trivial
Hash comparison in the clientSecret may still be brute-forced or the check may be patched
Encrypted validation codeRuntime analysis can recover the plaintext
Custom VMMore reversing work, same trust boundary
Server-side verificationSecret can remain outside the client

The important distinction is between raising cost and creating a real security boundary.

A custom VM raises cost.

A server-side verifier creates a boundary.

Those are very different jobs.


What Does the Obfuscated Validator Actually Do?

A generic validator can be modeled as six stages.

1. Anti-debugging
2. Runtime decryption
3. VM initialization
4. Input preparation
5. Mathematical validation
6. Final success/failure branch

The trick is that these stages are mixed together.

The first few minutes of analysis can feel like trying to understand a washing machine by staring at the instruction manual for the toaster.

That is normal.

You do not need to understand everything at once.


Stage 1: Start With Runtime Behavior

Before opening the binary in a decompiler, run it under a syscall or library tracer.

For example:

ltrace ./validator

A typical output might reveal:

malloc(...)
mmap(...)
mmap(...)
mmap(...)
ptrace(...)
exit(...)

The mmap() calls are immediately interesting because the program is repeatedly creating new memory regions.

The combination of:

mmap(...)
mmap(...)
mmap(...)
...
ptrace(...)

is worth investigating before touching the hundreds of lines of decompiler output.

Why ptrace() Matters

One common anti-debugging trick is:

ptrace(PTRACE_TRACEME, 0, NULL, NULL);

If the process is already being traced, the call can fail.

The program then exits.

That makes normal debugging annoying, which is presumably the point.

The important part for the analyst is not "this is impossible to debug."

It is:

We found a branch whose only job is to stop us from debugging.

That is something we can isolate and neutralize.

For example, in a controlled reversing environment, the return value can be modified in the debugger so execution continues normally.


Stage 2: Find the Runtime-Decrypted Code

Now open the binary in Ghidra, IDA, Binary Ninja, or your preferred disassembler.

Even with symbols stripped, the control flow eventually leads to a function responsible for setting up the runtime environment.

The interesting pattern is usually:

void *region = mmap(
    NULL,
    size,
    PROT_READ | PROT_WRITE | PROT_EXEC,
    MAP_PRIVATE | MAP_ANONYMOUS,
    -1,
    0
);

Then some data is written into region.

That combination is suspicious for two reasons:

  1. The memory is executable.
  2. The contents did not exist there in their final form at process start.

That strongly suggests runtime code generation, decryption, unpacking, or some other form of self-modifying behavior.

The first useful question is therefore:

What does each mapped region contain after initialization?


Dumping the Mapped Regions

A small GDB helper can capture the contents of memory immediately after each mmap() returns.

The generic idea is:

break mmap

commands
    silent

    set $len = $rsi
    finish

    set $addr = $rax

    printf "mapped %p, size %lu\n", $addr, $len

    dump memory
        /tmp/region.bin
        $addr
        $addr+$len

    continue
end

For repeated regions, keep the addresses and lengths in arrays and dump each one to a separate file.

Once those files are available, disassemble them independently.

You may find that every region begins with a valid function prologue or endbr64, followed by ordinary machine code.

That is the key moment.

The program is not hiding an unknown language forever.

It is hiding normal machine code until runtime.


Stage 3: Realizing You Are Looking at a VM

Eventually, another function starts looking suspiciously like a VM dispatcher.

A simplified version might look like this:

for (;;) {
    uint8_t opcode = *ip;

    if (opcode == VM_EXIT)
        break;

    handlers[opcode](
        &ip,
        &sp,
        &stack,
        &state
    );
}

This pattern is extremely recognizable.

You have:

  • An instruction pointer
  • An opcode
  • A dispatch table
  • A set of handler functions
  • A loop
  • A termination opcode

Congratulations.

Someone built a tiny CPU inside a normal CPU.

Because apparently x86 needed a roommate.


Finding the VM Bytecode

Once the dispatcher is understood, identify the array or memory region that contains the VM instructions.

It may look like random bytes:

03 00 00 00
12 17 00 00
19 8A 13 00
05 00 00 00
...

Random-looking is exactly what bytecode tends to look like when you do not know the instruction format.

A useful clue is the instruction width.

If you see repeated four-byte records, try splitting the stream into 4-byte instructions.

A generic parser might be:

def split_instructions(words, width=4):
    return [
        words[i:i + width]
        for i in range(0, len(words), width)
    ]

Then map the last field, first field, or another likely byte to the opcode.

You can confirm the guess by matching values against the dispatcher.


Build an Opcode Table

Do not try to understand the entire VM at once.

Build a tiny table.

For example:

OpcodeMeaning
0x00Get input length
0x01Shift left
0x02Modulo
0x03Load user input
0x04Divide
0x05Add
0x06Multiply
0x07Anti-debugging wrapper
0x08OR
0x0BJump
0x0DPassword loop
0x10Copy value
0x11Inner loop
0x12Load password byte
0x13Compare
0x16Conditional branch
0x17Relative move
0x18Subtract
0x19Load immediate
0x1AShift right
0x1CReturn

You do not need a perfect emulator yet.

You just need enough semantics to understand where the password bytes go.


Stage 4: Follow the Password Bytes

The most useful instruction in this kind of validator is usually the one that reads a character from the original input.

A simplified handler might be:

uint8_t value = password[index];
push(value);

From the VM's perspective, this may happen in a strange order:

password[24]
password[14]
password[27]
password[15]

password[11]
password[7]
password[12]
password[4]

...

That is not random.

It is a permutation.

The VM is effectively saying:

Give me character 24, then 14, then 27, then 15, and I will call that a chunk.

At this point the scary part of the validator starts becoming boring.

That is good.

Boring code is much easier to solve.


Stage 5: Recover the Final Arithmetic

Suppose four password characters are packed into one 32-bit integer:

def pack_chunk(chars):
    return (
        chars[0]
        | (chars[1] << 8)
        | (chars[2] << 16)
        | (chars[3] << 24)
    )

Then the VM applies a rotate:

encoded = rol32(chunk, rotation)

and compares it with a constant:

if encoded != expected:
    fail()

That looks complicated until you remember that bit rotations are reversible.

If:

encoded = ROL32(chunk, rotation)

then:

chunk = ROR32(encoded, rotation)

That is the whole trick.


Reversing the Check

The inverse operation is:

MASK32 = 0xffffffff

def ror32(value, count):
    count &= 31

    if count == 0:
        return value & MASK32

    value &= MASK32

    return (
        (value >> count)
        |
        (value << (32 - count))
    ) & MASK32

Given:

expected = 0xDEADBEEF
rotation = 11

we calculate:

chunk = ror32(expected, rotation)

Then unpack it:

chars = bytes([
    chunk & 0xff,
    (chunk >> 8) & 0xff,
    (chunk >> 16) & 0xff,
    (chunk >> 24) & 0xff,
])

That gives us the original four password bytes.

Repeat this for all chunks and put the recovered bytes back into their original positions.


A Generic Solver

The final solver does not need to emulate every instruction once the final checks are known.

For example:

CHECKS = [
    ([24, 14, 27, 15], 7,  0x12345678),
    ([11,  7, 12, 4],  14, 0x87654321),
    # ...
]

password = bytearray(b"?" * 32)

for positions, rotation, target in CHECKS:
    chunk = ror32(target, rotation)

    chars = bytes([
        chunk & 0xff,
        (chunk >> 8) & 0xff,
        (chunk >> 16) & 0xff,
        (chunk >> 24) & 0xff,
    ])

    for position, char in zip(positions, chars):
        password[position] = char

print(password.decode())

Notice what happened.

The custom VM is no longer important.

The encrypted functions are no longer important.

The anti-debugging code is no longer important.

Once the validation equations are recovered, the rest is just bit manipulation and bookkeeping.

The giant security theater collapsed into four bytes at a time.


What If the Math Is More Complicated?

Sometimes the final operation is not just a rotate.

You may see:

x = ((x * A) + B) ^ C
x = rol32(x, R)
x = x mod N

You have three options.

1. Reverse the operations manually

If every operation is invertible, walk backward.

final
  |
  v
inverse XOR
  |
  v
inverse rotate
  |
  v
subtract B
  |
  v
multiply by inverse(A)

2. Use Z3

SMT solving is useful when the equations become unpleasant.

from z3 import *

x = BitVec("x", 32)

solver = Solver()

solver.add(
    RotateLeft(x, 7) == 0x12345678
)

if solver.check() == sat:
    print(solver.model()[x])

3. Emulate the VM

When there are loops, branches, and state changes, writing a small emulator is often faster than manually rewriting a page of decompiler output.

That is exactly why VM challenges are interesting.

The VM itself becomes the thing you reverse.


A Better Way to Debug the VM

A full debugger trace can become unreadable very quickly.

Instead, log only the state that explains the current instruction.

For example:

ip=0132 opcode=12 password[24] -> 0x41
ip=0135 opcode=19 push 0x00000007
ip=0138 opcode=05 add
ip=0142 opcode=1A shr 0x03
ip=0146 opcode=13 compare

This gives you a high-level execution trace without forcing you to mentally simulate registers, stack slots, and every unrelated instruction.

A tiny trace function is often enough:

def trace(ip, opcode, **state):
    print(
        f"ip={ip:04x} "
        f"opcode={opcode:02x} "
        f"{state}"
    )

When reversing an unfamiliar VM, observability is worth more than elegance.


Why the Custom VM Does Not Save the Design

A custom VM is a speed bump.

It can:

  • Hide intent from static analysis
  • Increase analyst workload
  • Slow automated scanners
  • Make casual patching harder
  • Complicate debugging

It cannot:

  • Keep secrets away from the client
  • Make client-side decisions trustworthy
  • Prevent runtime observation
  • Stop someone from instrumenting the verifier

The client needs the information to perform the check.

An analyst can observe the information while the check is happening.

That is the uncomfortable part.


What Would a Safer Design Look Like?

Do not put the secret validation decision entirely on the client.

A safer pattern is:

Client
  |
  | password / credential proof
  v
Server
  |
  | validation
  v
Protected secret / database

For a desktop application that needs offline functionality, use signed capabilities, asymmetric cryptography, short-lived authorization tokens, or another design where possession of the client binary does not reveal the protected secret.

The exact design depends on the product.

The principle does not.

If the client can independently prove the secret, the client probably knows enough to be studied.


Testing and Audit Points

When reviewing a suspicious password checker, ask:

  1. Does the binary contain the expected value or a reversible transformation of it?
  2. Is validation performed locally?
  3. Does the program create executable memory at runtime?
  4. Are functions decrypted immediately before use?
  5. Is there a dispatch table based on attacker-observable instruction values?
  6. Does the input get rearranged before validation?
  7. Are comparisons performed against constants embedded in the client?
  8. Can the validation branch be patched or traced?
  9. Does the application rely on obfuscation as the primary protection?
  10. Would moving verification server-side remove the secret from the client?

If the answers start sounding like "yes, yes, yes, and unfortunately yes," you probably found an expensive password checker whose biggest achievement is making your debugger sweat.


Defensive Design Checklist

CategoryActionGoal
Trust boundaryMove sensitive verification server-sideKeep secrets off the client
CredentialsNever embed plaintext secretsPrevent direct extraction
AuthorizationUse signed or server-issued tokensSeparate identity from local logic
ObfuscationTreat it as defense-in-depthSlow analysis, do not rely on it
Debug resistanceDo not consider anti-debugging a secretIt only raises effort
Binary reviewSearch for embedded constants and comparison logicFind accidental secret storage
Runtime reviewMonitor mmap, decryption, and generated codeDetect unpacking and VM execution

Common Myths

"The code is encrypted, so nobody can read it."

The CPU cannot execute encrypted instructions forever.

At some point they become instructions.

That is the point where you stop asking the file what it contains and start asking memory.

"The password is only stored as a hash."

If the client contains enough information to decide whether your guess is correct, you may still be able to brute-force the local check or patch the decision.

A hash is not automatically a security boundary.

"Anti-debugging makes it impossible to reverse."

It makes debugging annoying.

Those are not the same sentence.

"A custom VM is basically encryption."

No.

A VM is an execution mechanism.

Unless the actual secret is kept outside the client, you are still protecting the same information with more moving parts.


Final Thoughts

The interesting lesson from this class of binary is not how to write a fancier password checker.

It is how quickly the apparent complexity collapses once you identify the right abstraction.

At the beginning, you have:

anti-debugging
encrypted functions
dynamic memory
custom bytecode
jump tables
scrambled input
arithmetic

At the end, you have:

ROL32
   |
   v
ROR32
   |
   v
four bytes

That is reverse engineering in miniature.

Do not fight the whole binary at once. Find the state transition that actually decides the answer, then work backward from there.

A client can hide the path.

It cannot hide the destination from the machine that has to reach it.