Lacks's 10 Crackmes

karity·2026년 7월 11일

Crackme

목록 보기
12/16
post-thumbnail

1. Level 1

memcmp(input, "password")

2. Level 2

python:

data = [0x28, 0x3F, 0x2C, 0x3F, 0x28, 0x29, 0x3F]

pwd = ''
for val in data:
    pwd = f"{pwd}{chr(val ^ 0x5A)}"
    
print(pwd)
Enter Secret Key: reverse

[+] Access Granted! You solved it.

3. Level 10

Some interesting strings in the binary:

> strings level10.exe -d
...
=============================
    CRACKME LEVEL 10 (BOSS)
=============================
Enter Password:
[+] Correct! Decrypted flag:
 (Garbage!)
[-] Access Denied. Decrypted output:
Press Enter to exit...
RSDSe
...

A quick Hex-Rays look at the final comparison:

if ( goal == 0x3D17141A )
  {
    v10 = sub_1400017A0(std::cout, "\n[+] Correct! Decrypted flag: ");
    v11 = "\n";
  }
  else
  {
    v10 = sub_1400017A0(std::cout, "\n[-] Access Denied. Decrypted output: ");
    v11 = " (Garbage!)\n";
  }

Let's see what goal actually is.

goal is assigned here:

goal = dword_140006278;
...
  }
    goal = dword_140006278;
  }
  if ( goal == 0x3D17141A )

So goal is just *140006278.
Checking the write xref leads to this function:

int __fastcall sub_140001300(char *a1)
{
  int v1; // ebx
  char *v2; // r8
  _QWORD *v3; // r9
  char *v4; // rdx
  char *v5; // rcx
  int v6; // eax

  v1 = 0;
  if ( *(a1 + 3) <= 0xFui64 )
  {
    v4 = a1;
    v3 = a1;
    v2 = a1;
  }
  else
  {
    v2 = *a1;
    v3 = *a1;
    v4 = *a1;
  }
  v5 = v3 + *(a1 + 2);
  if ( v2 != v5 )
  {
    do
    {
      v6 = *v4++;
      v1 = v6 + 31 * v1;
    }
    while ( v4 != v5 );
  }
  if ( Mtx_lock(&unk_1400060C0) )
  {
    std::_Throw_Cpp_error(5);
    __debugbreak();
  }
  if ( dword_14000610C == 0x7FFFFFFF )
  {
    dword_14000610C = 2147483646;
    std::_Throw_Cpp_error(6);
    __debugbreak();
  }
  dword_140006278 = v1; // here!
  return Mtx_unlock(&unk_1400060C0);
}

This function is invoked via thread.join:

sub_1400017A0(std::cout, "Enter Password: ");
sub_140001970(std::cin, &v22);
sub_140001C10(&v21, v0, &v22);
sub_140001C10(&v21, v0, &v22);
// ↑
// v6[4] = func; // our above function  ↓ this call func
// result = beginthreadex(0i64, 0, StartAddress, v6, 0, (a1 + 8)); here
  if ( !v21._Id )
  {
    std::_Throw_Cpp_error(1);
    __debugbreak();
  }
  if ( v21._Id == Thrd_id() )
  {
    std::_Throw_Cpp_error(5);
    __debugbreak();
  }
  v17 = v21;
  if ( Thrd_join(&v17, 0i64) )
  {
    std::_Throw_Cpp_error(2);
    __debugbreak();
  }
  v21 = 0i64;
  goal = dword_140006278;
...

The core logic is simple:

 v5 = &v3[*(a1 + 2)];
  if ( v2 != v5 )
  {
    do
    {
      v6 = *v4++; // next ptr
      v1 = v6 + 31 * v1; // char * 31
    }
    while ( v4 != v5 ); // iterate input
  }

Equivalent Python:

x = 0

pwd = "123"

for c in pwd:
    x *= 31
    x += ord(c)
    
print(hex(x))

So how do we find a valid input?
Let's figure out the length first.

The lowest printable ASCII character we can input is !.
Let's fill every position with it and see what x becomes:

goal is 3D17141A (8 digits)
3 len / !!! is 0x8001 (4 digits)
4 len / !!!! is 0xf8040 (5)
5 len / !!!!! is 0x1e087e1 (7)
6 len / !!!!!! is 0x3a307460 (8)
7 len / !!!!!!! is 0x70bde17c1 (9)

So length 6 is the right ballpark — length 7 already overshoots goal even in the best case (all !), and lengths ≤5 can't reach 8 hex digits at all.
To be sure length 5 truly can't reach it, test the highest printable character too, e.g. z:

5 len / zzzzz is 0x6f081fa

Still not enough. So length 6 is the only possible length.

Now we can find the input using binary search — since x = x*31 + byte, an earlier character in the password has a far larger effect on the final value than a later one (Horner's-method style weighting: 31^(len-1-i)). This means the value is monotonic with respect to each position, so each character can be pinned down independently with a binary search: fix a prefix, fill the rest with the minimum char, and binary-search the prefix's last character until the resulting value straddles goal. Repeat left to right.

answer = b'"6656='  hash=0x3D17141A  target=0x3D17141A  diff=0

Now feed it to binary:

=============================
    CRACKME LEVEL 10 (BOSS)
=============================
Enter Password: "6656=
[+] Correct! Decrypted flag: Perfect!

4. Ultimate Boss Level - Final

First checked how input is handled: username → part1 → part2 → compared against something → invalid! on mismatch.

Threw it in a debugger, but hit multiple anti-debug checks stacked together (well-known methods). Patched all of them out to get a clean trace.

Still obfuscated, so instead of fighting the obfuscation directly, just traced execution step by step — watching how each input byte got consumed and transformed — and reimplemented the logic piece by piece as each operation was identified. Once everything was mapped out, wrote the keygen in Python.

The core generation logic is simple:

part1 = sum(username) * 31
part2 = (part1 ^ 0x1337) + 0x7B

Keygen worked — valid usernames now pass the check. But after the "valid" message, a second Message: field is decrypted based on the input, and that part came out garbled for arbitrary valid inputs.

Didn't reverse the message-decryption logic itself (The key from Level 10 was 1 byte, easy to bruteforce). So instead, just ran the keygen with a handful of guessable usernames (author's nickname, "admin", etc.) and checked whether the decrypted message came out meaningful.

"crackme" turned out to be the one that decrypts into a real message:

 [+] Access Granted!
[+] Message: wOWYOUDIDIT

0개의 댓글