Schizo

unemployed crackme

  • Category: rev
  • Difficulty: 3.0
  • Challenge author: S3c_Cult
  • Challenge link: crackmes.one
  • Language: C/C++ — the challenge page says Go, which is the first lie of several
  • Platform: Linux x86-64 (ELF PIE, dynamically linked against OpenSSL)

There is no flag format given. The name is the entire hint: every piece of metadata the binary offers about itself contradicts every other piece, and none of it is true. The description claims Go, the symbol table claims Rust, the section table claims both, and the imports say plain C++. None of that misdirection is load-bearing — the real challenge is a three-stage unwrap (self-derived AES key → dropped shared library → a home-made block cipher) sitting behind a flag that never exists in the binary at all, because it arrives over HTTP.

Solution:

1. Every claim the file makes about itself is false

The drop is a binary, an opaque lib.so, and a stub HTTP server.

$ file schizo lib.so
schizo: ELF 64-bit LSB pie executable, x86-64, dynamically linked,
        interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 4.4.0, not stripped
lib.so: data

lib.so is not a library — 99584 bytes at 7.998 bits/byte entropy over all 256 values, i.e. ciphertext, and a multiple of 16. “not stripped” on schizo is also bait:

$ nm schizo | wc -l
1
$ nm schizo
0000000000009171 t rust_panic

One planted symbol, named to suggest Rust, and enough to make file report a symbol table. The section headers keep the joke going:

$ readelf -SW schizo | sed -n '15,22p'
  [15] .sforce2          PROGBITS  00000000000b7d10 0b7d10 0003c6 00  AX
  [16] .gosymtab         PROGBITS  00000000000b80d6 0b80d6 000007 00  AX
  [17] .gopclntab        PROGBITS  00000000000b80dd 0b80dd 000007 00  AX
  [18] .go.buildinfo     PROGBITS  00000000000b80e4 0b80e4 000007 00  AX
  [19] .note.go.buildid  PROGBITS  00000000000b80eb 0b80eb 000007 00  AX
  [20] borland.resspare  PROGBITS  00000000000b80f2 0b80f2 000007 00  AX
  [21] .HP.init          PROGBITS  00000000000b80f9 0b80f9 000007 00  AX

Four fake Go markers, one fake Borland and one fake HP section, seven bytes each. The odd one out is .sforce2 — 966 bytes, executable, and the only one with real content. Remember it.

What the binary actually is shows up in the imports, which no amount of section forgery can hide: EVP_aes_256_cbc, EVP_DecryptInit_ex/Update/Final_ex, SHA256, mkstemp, fchmod, dlopen, dlsym, unlink, ptrace, socket/connect/getaddrinfo, and a wall of std:: / __cxa_*. That is a C++ program that decrypts something, drops it, loads it, deletes it, and talks to the network.

Since the symbol table is a decoy, main comes from the entry stub:

8c58:  lea    rdi,[rip+0x1d63]        # a9c2  <- main
8c5f:  call   QWORD PTR [rip+0xfb373] # __libc_start_main
2. main is the whole program in forty lines

Decompiling 0xa9c2 lays out the entire structure:

sub_8e7b(v5);                                    // build key material
sub_1361c(v7, "lib.so", v6);
sub_2894f(v8, v7, v5);                           // AES-decrypt lib.so
v15 = sub_8d3a(v8, &v9);                         // drop + dlopen
if (v15) {
  dat_1046d0 = dlsym(v15, "___1");
  dat_1046d8 = dlsym(v15, "___3");
  dat_1046e0 = dlsym(v15, "___4");
  sub_1361c(v10, "`\\wiXh4s:%Vs:DKsIa<w_U^Wvk\"7)fCzW,%XD6.<:8C:vk$dprRu@;^tW7", v6);
  sub_941d(v11, v10);                            // decode
  (*dat_1046e0)(v7, v11);                        // ___4 -> plaintext
  std::ostream::operator<<(std::operator<<(_ZSt4cout, v7), dat_103fc0);
  for (v13 = 0; v13 < sub_31902(0x104610); v13++) {
    ...
    v14 = sub_3dada(sub_3da74(v6, 0));           // index
    sub_3db0c(v10, sub_3da74(v6, 1));            // payload
    sub_9653(v11, v7);                           // <- fetch
    sub_2ff88(sub_33c78(0x104650, v14), v11);    // store at index
  }
  sub_a286();                                    // verdict
  dlclose(v15); close(v9);
}

Three globals matter: 0x104610, 0x104630 and 0x104650, all in .bss.

3. The AES key is the binary’s own code

sub_8e7b never touches a hardcoded key. It re-opens itself and reads a section out of its own image:

v4 = open("/proc/self/exe", 0);
fstat(v4, v7);
v5 = mmap(0, v12, 1, 2, v4, 0);
v2 = *(long *)(v5 + 0x28);                       // e_shoff
v3 = *(long *)(v5 + *(u16 *)(v5 + 0x3e) * 0x40 + v2 + 0x18);   // shstrtab
while (...) {
  v1 = *(u32 *)(v5 + v9 * 0x40 + v2);
  if (!strcmp(v5 + v1 + v3, ".sforce2")) break;  // find .sforce2
  v9 += 1;
}
for (v10 = 0; v10 < <sh_size>; v10++) {
  sub_3d23c(v8, 6, "{:02x}", v5 + v10 + v3);     // hex-encode each byte
  sub_30c84(a0, v8);
}

So the key material is the lowercase hex dump of .sforce2’s 966 bytes — 1932 characters. sub_2894f then hashes it and decrypts:

sub_287fe(&v10, a2);                             // SHA256(key material) -> 32 bytes
v13 = EVP_CIPHER_CTX_new();
EVP_DecryptInit_ex(v13, EVP_aes_256_cbc(), 0, sub_28902(&v10), sub_3c720(0x104670));

with sub_287fe being a one-liner around SHA256(...). The IV global is built by the static constructor at sub_b06f from .rodata:

sub_28148(0x104670, 0xc0290, 0x10, &v1);         // 16 bytes at 0xc0290
sub_2bc2a(0x104610, 0x102d60, 0x102d88, &v1);    // 5 const char*
sub_2bc2a(0x104630, 0x102da0, 0x102dc8, &v1);    // 5 const char*
sub_2bc2a(0x104650, 0x102de0, 0x102e08, &v1);    // 5 empty strings
$ dd if=schizo bs=1 skip=$((0xc0290)) count=16 2>/dev/null | xxd
00000000: 0001 0203 0405 0607 0809 0a0b 0c0d 0e0f  ................

key = SHA256(hex(.sforce2)), iv = 00 01 .. 0f, AES-256-CBC. Because .sforce2 is executable code that the program runs, this doubles as an integrity check: patch a single byte anywhere in it and the key changes and lib.so will not decrypt.

sec = exe[0xb7d10:0xb7d10 + 0x3c6]
key = hashlib.sha256(''.join('%02x' % b for b in sec).encode()).digest()
# 098a5c8aebbe8a087e10f4a0a60b9238f41b5566be57068101f5cfd903d9c6f5
pt = AES.new(key, AES.MODE_CBC, bytes(range(16))).decrypt(open('lib.so','rb').read())
pt = pt[:-pt[-1]]                                # PKCS#7, 8 bytes
$ file lib_dec.so
lib_dec.so: ELF 64-bit LSB shared object, x86-64, dynamically linked, not stripped

And this one really is not stripped:

$ nm -C lib_dec.so | grep -E ' [TtWw] ' | grep -v 'std::\|__gnu\|_GLOBAL'
00000000000093cd T ___1
0000000000009c2d T ___3
0000000000009cf4 T ___4
0000000000009dc0 T to_hex(...)
0000000000008edb t xorshift32(unsigned int&)
0000000000008e59 t rotate_left(unsigned char, int)
0000000000008e9a t rotate_right(unsigned char, int)
0000000000009085 t build_permutation_sequence(unsigned int, int, unsigned long)
0000000000008f30 t prng_byte(unsigned int&)

The loader itself (sub_8d3a) is unsubtle about its intentions:

builtin_strncpy(v4, "/tmp/.trojanXXXXXX", 0x13);
v1 = mkstemp(v4);
write(v1, sub_3c720(a0), sub_28926(a0));
fchmod(v1, 0x140);                               // 0500
v3 = dlopen(v4, 2);
unlink(v4);                                      // gone before it is even used
4. Two stacked string layers

Every literal in the binary goes through sub_941d and then ___4. sub_941d wraps sub_afebb, which is a codec worth reading carefully because it is not ascii85 — the radix is 0x5b:

while (v2 = v4 + -1, v4) {
  if (v6 != 0xffffffff) {
    v5 += 0xd;                                                   // 13 bits per pair
    v7 |= ((*v8 - 0x21) + v6 * 0x5b) << (0x20 - v5 & 0x1f);      // radix 91
    do { *(v9 + a2) = v7 >> 0x18; v7 <<= 8; v5 -= 8; v9++; } while (8 <= v5);
    v6 = 0xffffffff;
  } else {
    v6 = *v8 - 0x21;                                             // digit = c - '!'
  }
  v8 = v1;
}

Two characters, digit value c - 0x21, radix 91, packed as 13 bits MSB-first — a fixed-width basE91 variant. Mistaking it for ascii85 costs you an afternoon: base-85 decodes the first pair of every string to something three off the right answer, which looks like an off-by-one rather than a wrong radix.

___4 is the second layer, and ___3 in the library is its inverse, which is how you confirm the direction:

// ___4 (decrypt)                      // ___3 (encrypt)
v1 = rotate_right(~(s[i] - (i + (i<<3))), 3);
                                       v1 = rotate_left(s[i], 3);
                                       s[i] = ~v1 + (i<<3) + i;

So out[i] = ror8(~(in[i] - 9*i), 3). Running both layers over the literals turns the program legible:

`\wiXh4s:%Vs:DKsIa<w_U^Wvk"7)fCzW,...   -> "I know you're watching me.\nAre you one of them?"
?$cE+p'.Y`\S1                            -> "User-Agent"
DMbA6R@/;N^%Q                            -> "schizo 1.0"
6]yW*zKOY`\SLX84\H                       -> "X-Fragment-Key"
3L]eD<;sZY\]GR#k\WQ`ukbiKS               -> "you are not my friend"
^d_oDYHw:D5@M0&"2@=D^ty9JoJlRU^p#K       -> "\nno :) well the truth is\\\"
EcS,dm);vLD=hUNP1vf^rpWBCw               -> "http://127.0.0.1:5566"   (base91 only, no ___4)

That last one is the only literal with a single layer, and it explains the srv.py in the drop.

5. The flag is never in the binary — it is fetched

sub_9653 builds the request out of those strings:

sub_1361c(v4, "EcS,dm);vLD=hUNP1vf^rpWBCw", v3);  // http://127.0.0.1:5566
...
(*dat_1046e0)(v10, v9);   // "User-Agent"
(*dat_1046e0)(v13, v12);  // "schizo 1.0"
(*dat_1046e0)(v4,  v16);  // "X-Fragment-Key"
sub_3d7b2(v14, v4, a1);                           // X-Fragment-Key: <arg>
sub_28078(v3, v6, "/", v19, v5);                  // GET /
if (sub_dd4e(v3) != 1)  sub_1361c(a0, "", ...);   // failure -> empty
else                    sub_1aa76(a0, sub_dd70(v3) + 0xb8);   // else response body

Five GETs, each carrying a different X-Fragment-Key, and the response body becomes the fragment. The shipped srv.py answers SILLY!! to everything, so the stock setup always fails. Whatever the server is supposed to return is the flag — which means the flag has to be recoverable from the comparison data.

6. Indexed tables, and sub_9cc9

The two const char* tables read straight out of .rodata:

$ dd if=schizo bs=1 skip=$((0xc02a0)) count=96 2>/dev/null | xxd
00000000: 3171 6100 3236 4641 3234 0032 5652 2a00  1qa.26FA24.2VR*.
00000010: 3276 3234 336b 6f70 6100 333b 462a 5e26  2v243kopa.3;F*^&
00000020: 0031 716e 3879 7b44 3357 505a 627a 3e00  .1qn8y{D3WPZbz>.
00000030: 3236 5f6f 5066 4723 4f53 7a4f 2461 4100  26_oPfG#OSzO$aA.
00000040: 3256 604b 7458 593c 4c6d 4c6a 3f39 2100  2V`KtXY<LmLj?9!.
00000050: 3276 6234 7571 6578 5e41 6f7b 0033 3b6d  2vb4uqex^Ao{.3;m

The entries pair up on a shared two-character prefix — 1q, 26, 2V, 2v, 3; — and sub_9cc9 is what separates prefix from payload. It builds a growing prefix one character at a time, base91-decodes the whole prefix each iteration, and stops as soon as the result is all digits:

sub_2f282(v3, (int)v13);      // v3.push_back(c)  -- v3 accumulates
sub_1aa76(v4, v3);
sub_941d(v5, v4);             // base91-decode the prefix so far
sub_2ecc6(v2, v5);            // v2 = that   (assign, not append)
if (sub_9585(v2)) {           // non-empty && all digits?
  sub_95e1(v5, v6, v4);       //   strip the prefix off the original
  v7 = sub_b9f1(v2, 0, 10);   //   stol -> index
  return {to_string(v7), v5};
}

Two characters is always enough, and they decode to exactly 04 in both tables:

'1q' -> b'0'    '26' -> b'1'    '2V' -> b'2'    '2v' -> b'3'    '3;' -> b'4'

which splits them into a key table and an expected-value table:

idx X-Fragment-Key (0x104610) expected (0x104630)
0 a n8y{D3WPZbz>
1 FA24 _oPfG#OSzO$aA
2 R* `KtXY<LmLj?9!
3 243kopa b4uqex^Ao{
4 F*^& m;@[q4ob)!]IQ
7. ___1 stores its own key in its output

sub_a286 is the verdict. For each stored response it computes sub_a1b0 and compares against the table:

sub_a1b0(v10, sub_33c78(0x104650, v3));   // transform the fetched fragment
...
if ((v19 != v20) || (!sub_349d5(v12, v10)))  v1 = 0; else v1 = 1;

and sub_a1b0 is just two calls:

(*dat_1046d0)(v1, a1);        // ___1
sub_3da24(v2, v1);
sub_91af(a0, v2);             // base91-encode

So expected[i] == base91_encode(___1(fragment[i])). The fragments are raw; only the comparison is transformed. Inverting ___1 gives the flag directly — and ___1 turns out to be a textbook SPN whose key schedule is derived from the plaintext:

for (each byte) v18 += *v16[0];                              // byte sum
v19 = v18 * -0x7a143595 ^ v7 * -0x61c8864f ^ 0xa3c59ac3;     // 0x85EBCA6B / 0x9E3779B1
if (!v19) v19 = 0xcafebabe;
v5 = ((unsigned char)v19 & 7) + 6;                           // 6..13 rounds
build_sboxes(v19 ^ 0xa5a5a5a5, v11, v10);                    // sbox + inverse
build_permutation_sequence(v12, v19 ^ 0x3c3c3c3c, v5);
v13 = v19 ^ 0xc3c3c3c3;                                      // round-key PRNG seed

with a round of

state[i] ^= K1[r][i];
state[i]  = sbox[state[i]];
apply_permutation(perm[r], state, tmp); swap(state, tmp);    // out[i] = in[perm[i]]
state[i]  = rotate_left(state[i], (r + i) & 7);
state[i]  = state[i] + K2[r][i];

Everything hangs off v19, and v19 depends on the plaintext’s byte sum — which should make inversion a search over the sum. It isn’t, because of the last four lines of the function:

std::__cxx11::basic_string::reserve(a0, v8 + 5);
pack_u32_be(a0, v19);                            // <- the seed, in the clear
std::__cxx11::basic_string::push_back(a0, v5);   // <- the round count, in the clear
for (v27 = 0; v27 < v8; v27++) push_back(a0, state[v27]);

___1 prepends its own seed and round count to its ciphertext. The output is be32(seed) || rounds || ct, so every parameter needed to run the cipher backwards is sitting in the first five bytes of each table entry. Checking that against the entries confirms it before writing any inverse:

idx  seed      rounds  (seed & 7) + 6   ct
 0   dbb7e899  7       7  OK            361a427f
 1   b2c43e9b  9       9  OK            08cfe88546
 2   b47f6e28  6       6  OK            f957c92b08
 3   b9778b31  7       7  OK            5cfe0a
 4   d8f2cff9  7       7  OK            bfb16c55f3

The primitives are small enough to reimplement verbatim — xorshift32 is s ^= s<<13; s ^= s>>17; s ^= s<<5, prng_byte returns bits 8–15, both shuffles are Fisher–Yates over that stream, and apply_permutation is out[i] = in[perm[i]] so the inverse is out[perm[i]] = in[i]. Running each round backwards (solve.py):

$ python3 solve.py
[*] AES-256 key : 098a5c8aebbe8a087e10f4a0a60b9238f41b5566be57068101f5cfd903d9c6f5
[*] IV          : 000102030405060708090a0b0c0d0e0f
[+] lib_dec.so  : 99576 bytes, magic b'\x7fELF'

idx  X-Fragment-Key  seed      rnds  fragment
  0  'a'            dbb7e899  7     b'CMO{'
  1  'FA24'         b2c43e9b  9     b'Th3y_'
  2  'R*'           b47f6e28  6     b'L1K3_'
  3  '243kopa'      b9778b31  7     b'T0_'
  4  'F*^&'         d8f2cff9  7     b'h1d3}'

FLAG: CMO{Th3y_L1K3_T0_h1d3}

Two assertions guard each fragment: the recovered plaintext’s byte sum must regenerate the stored seed, and re-encrypting must reproduce the table entry byte for byte. Both hold for all five, so this is the plaintext, not merely a plaintext.

8. Make the binary say it

Static recovery is not proof. Serving the fragments back over the real socket is (solve_srv.py):

FRAG = {'a':'CMO{', 'FA24':'Th3y_', 'R*':'L1K3_', '243kopa':'T0_', 'F*^&':'h1d3}'}

def do_GET(self):
    body = FRAG.get(self.headers.get('X-Fragment-Key', ''), '').encode()
    self.send_response(200)
    self.send_header('Content-Length', str(len(body)))
    self.end_headers()
    self.wfile.write(body)

Run against the pristine binary, no patches, no debugger:

$ md5sum schizo
ae49aea1c891ff1dc8acdc232956c861  schizo

$ python3 solve_srv.py &
$ ./schizo
I know you're watching me.
Are you one of them?
CMO{Th3y_L1K3_T0_h1d3}

no :) well the truth is\\\
aHR0cHM6Ly9pbmRlZWQuY29t

with the server logging exactly the five expected keys:

[srv] UA='schizo 1.0' key='a'       -> b'CMO{'
[srv] UA='schizo 1.0' key='FA24'    -> b'Th3y_'
[srv] UA='schizo 1.0' key='R*'      -> b'L1K3_'
[srv] UA='schizo 1.0' key='243kopa' -> b'T0_'
[srv] UA='schizo 1.0' key='F*^&'    -> b'h1d3}'

The parting aHR0cHM6Ly9pbmRlZWQuY29t is base64 for https://indeed.com, which is the “unemployed crackme” landing where it belongs.

Flag: CMO{Th3y_L1K3_T0_h1d3}

Takeaways

A binary that derives its own decryption key by re-reading /proc/self/exe has quietly built a tamper check into the unpack step. Here the key is SHA256(hex(.sforce2)) over a code section the program also executes, so patching an anti-debug branch or a comparison inside that section silently corrupts the AES key and the payload never decrypts. The tell is a section with AX flags that nothing references as code you can find — read the key derivation before reaching for a patch, or you will be debugging the wrong failure.

Radix errors in hand-rolled codecs are quiet. This one packs two digits into 13 bits, which looks like ascii85 in the decompiler and even round-trips short strings into near-ASCII, but the multiplier in the listing is 0x5b. Reading it as base-85 yields output three off in the first byte — close enough to blame a shift or an off-by-one rather than the constant sitting right there. When a codec is reimplemented from decompiled arithmetic, take the constants literally and validate against a known-plaintext string (http://127.0.0.1:5566 here) before trusting anything else it decodes.

A cipher whose key schedule depends on the plaintext is not necessarily hard to invert — check whether it has to transmit the derived key. ___1 seeds everything from the byte sum and length of its input, which would ordinarily force a search over the sum (a few thousand candidates per fragment, still cheap). But it has to be able to decrypt too, so it prepends the seed and round count to its own output in the clear. One pack_u32_be at the end of the function turned a search into a direct inversion.

When the check compares a transform of the secret rather than the secret itself, work out which side is printed. sub_a286 compares base91(___1(fragment)) against a table but prints the raw fragment, so the table is not the answer and reading it as such gives 61 bytes of noise. Tracing which value reaches cout — rather than which value reaches the comparison — is what identifies the thing worth inverting.