C:\philes\xor-reverse: Cracking a simple XOR-obfuscated password check
_×

Cracking a simple XOR-obfuscated password check

posted: August 02, 2026

So I've been looking for random files to reverse engineer and crack, where I stumbled upon this file. Looking at it, it looks like it requires a password that has to be entered in order for the program to work. So let's launch a new session on IDA Pro and get started!

Step 1: Finding the failure path

First, by searching for the string "ACCESS DENIED" that gets std::cout'd to the display, we find the function responsible for that print, which is at address .text:0000000140017ED0. From outside it's just random bullshit that prints the access denied message — no useful logic there, just a dead-end print statement. Our goal isn't this function itself, it's whatever calls it, since that's where the actual pass/fail decision gets made. Our real target is the function that compares the password or gives ACCESS GRANTED.

Stdout for the ACCESS DENAIED
Stdout for the ACCESS DENAIED

Step 2: Finding the flag string

So let's scroll up a bit and voilà, we found the flag string sitting in plain text in .rdata: "FLAG: CRACKME{X0R_CR4CK3D}". No encryption, no obfuscation on the flag itself — it's stored as a raw readable string. Looking at it, seems like it just displays the flag after getting access with the correct password, meaning this string only gets referenced from the "success" branch of the program. That makes it a perfect anchor point — if we find what code touches this string, we find the success path.

So let's go to the function that calls the flag, using IDA's cross-reference feature, which is DATA XREF: sub_140017CD0+1BA up. This tells us exactly which function and offset references the string, letting us jump straight there instead of manually scrolling through the whole binary. Where we see this:

.text:0000000140017E8A    lea     rdx, aFlagCrackmeX0r ; "FLAG: CRACKME{X0R_CR4CK3D}"
.text:0000000140017E91    mov     rcx, cs:?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A ; std::ostream std::cout
.text:0000000140017E98    call    sub_1400110C8
.text:0000000140017E9D    lea     rdx, sub_140011055
Finding the flag for main logic
Finding the flag for main logic

The most important line here is call sub_1400110C8, which tells us it's most likely calling sub_1400110C8 to print the FLAG — this is basically just a wrapped std::cout << call under the hood, the compiler's mangled C++ naming makes it look scarier than it is. This whole block's only job is: load the flag string into a register, load cout into another, call the print function. Nothing computational happening here, it's purely cosmetic output — the "you win" screen.

Step 3: Tracing back to the password entry

From there we go up a little bit to find where the password logic is, which is under the "ENTER PASSWORD" function — identifiable by another string reference, this time to "[+] Enter password: ". Looking at it, we see the program:

Prints the password prompt. Reads user input via std::cin into a local buffer (var_128). Takes that buffer and calls sub_1400012DA, passing the input as an argument via rdx/rcx.

This is most likely the target that compares the password with the actual one — the return value (al, a single byte) is almost certainly a boolean pass/fail flag that later code branches on to decide whether to print ACCESS GRANTED or ACCESS DENIED.

Step 4: Decompiling the check function

So we go into sub_1400012DA, hit F5 to get the decompiled (Hex-Rays) version instead of trying to read raw assembly by hand, and find the password logic:

sub_1400116D6(&unk_14002D103);
qmemcpy(correct_pass, "8'dffb", 6);
correct_pass[32] = 85;
compare_pass = sub_140011604(pass_input);
if ( compare_pass == 6 )
{
    for ( j = 0; j < 6; ++j )
    {
        v10 = correct_pass[j] ^ 0x55;
        v11 = v10;
        compare_pass = (unsigned int)*(char *)sub_140011028(pass_input, j);
        if ( v11 != (_DWORD)compare_pass )
        {
            LOBYTE(compare_pass) = 0;
            goto LABEL_12;
        }
        compare_pass = (unsigned int)(j + 1);
    }
    LOBYTE(compare_pass) = 1;
}
else
{
    LOBYTE(compare_pass) = 0;
}
LABEL_12:
v4 = compare_pass;
sub_1400115BE(v6, &unk_140023030);
return v4;
main pass logic compare
main pass logic compare

Step 5: Breaking down the logic

This is a simple XOR obfuscation scheme, not real encryption — no key derivation, no rounds, just a static single-byte XOR mask applied per character. Let's go through it piece by piece:

qmemcpy(correct_pass, "8'dffb", 6); copies the 6 raw obfuscated bytes 8'dffb into a local buffer (v8, which I'm calling correct_pass for clarity). These 6 bytes are the encoded password, sitting statically in the binary — anyone dumping strings from this file would see 8'dffb and think it's meaningless garbage, which is exactly the point of the obfuscation.

compare_pass = sub_140011604(pass_input); is a length check, functionally equivalent to strlen(pass_input). It measures how many characters you typed.

if ( compare_pass == 6 ) is a hard gate: if your input isn't exactly 6 characters long, it skips straight to failure without even bothering to check individual characters. This is a nice early-exit for us reversers too — it tells us immediately the password is fixed-length, 6 characters.

Inside the loop: v10 = correct_pass[j] ^ 0x55; is the actual decoding step. Each of the 6 stored bytes gets XORed against the constant 0x55 to reveal the real character.

compare_pass = (unsigned int)*(char *)sub_140011028(pass_input, j); grabs the character you typed at position j (basically pass_input[j]).

if ( v11 != (_DWORD)compare_pass ) compares the decoded real character against your typed character. Any single mismatch sets the result to 0 (fail) and jumps straight to LABEL_12, skipping the rest of the loop — meaning it doesn't even bother checking the remaining characters once one fails.

If the loop completes all 6 characters without any mismatch, LOBYTE(compare_pass) = 1; sets the return value to 1 (success).

Step 6: Manually decoding the password

Since we know the algorithm — stored_byte XOR 0x55 = real_character — we can decode all 6 bytes of "8'dffb" by hand:

stored char   ASCII/hex   XOR 0x55       binary check                     decoded char
8             0x38        0x38 ^ 0x55    00111000 ^ 01010101 = 01101101   0x6D = m
'             0x27        0x27 ^ 0x55    00100111 ^ 01010101 = 01110010   0x72 = r
d             0x64        0x64 ^ 0x55    01100100 ^ 01010101 = 00110001   0x31 = 1
f             0x66        0x66 ^ 0x55    01100110 ^ 01010101 = 00110011   0x33 = 3
f             0x66        0x66 ^ 0x55    01100110 ^ 01010101 = 00110011   0x33 = 3
b             0x62        0x62 ^ 0x55    01100010 ^ 01010101 = 00110111   0x37 = 7

Reading the decoded column top to bottom gives us the password: mr1337.

Conclusion

Typing mr1337 into the password prompt satisfies both checks — the length check (== 6) and the byte-by-byte XOR comparison — triggering the success path, which calls the print function for sub_1400110C8 with the flag string loaded in rdx, displaying FLAG: CRACKME{X0R_CR4CK3D} on screen. And like that, we solved this simple file — no dynamic analysis or debugger stepping needed, pure static analysis from string references down to a manual XOR decode.

solved password recovered.
solved password recovered.
Start Start
github
buffering...
9:41 AM