How to tackle an exotic, nested target: recon on an architecture you can't run, extraction of an embedded second layer, and offline recovery of an RC4-protected flag, using nothing but command-line tools and Python.
Get the challenge file
Want to follow along and do the analysis yourself? The challenge binary is available here: download chimera_outer (Google Drive).
Safety note: this is a CTF challenge that writes files to
/tmpand forks repeatedly when executed. Static analysis needs no execution at all, but if you decide to run it, do it inside an isolated VM or container.
In my previous write-up, on kindling, I argued for a workflow: recon, understand the logic, isolate the transformation, invert, validate. It's a workflow that scales. The natural follow-up question is: what happens when the target isn't a small, readable x86 ELF, but a 1.5 MB big-endian PowerPC binary that extracts a second executable from inside itself?
That's exactly what chimera_outer is, a challenge from the Malware Space Village. The name is literal here: a binary that wraps another binary, a chimera made of layers. The good news is that the method doesn't change. What changes is the discipline. With an architecture you can't execute natively, static analysis becomes the main path, and running the malware becomes optional: something you do only to confirm, never as a required step.
In this write-up I walk through the whole thing, with the real commands and outputs, so anyone can follow along.
Tools used in this walkthrough
| Tool | Role in the analysis |
|---|---|
file, readelf, nm, strings
|
Recon: architecture, symbols, textual hints |
llvm-objdump |
Disassembling PowerPC (the regular objdump can't) |
python3 (struct, hashlib) |
Extracting the embedded layer and rebuilding the crypto chain |
qemu-ppc-static (optional)
|
Only to confirm the static analysis at runtime |
A note on tooling: the stock binutils
objdumphere complainscan't disassemble for architecture UNKNOWN. For PowerPC,llvm-objdumpworks out of the box. And to run a PPC binary on an x86 machine there'sqemu-ppc-static. But, as you'll see, neither one is required to solve it.
Recon: a target that isn't your architecture
The first surprise comes from file:
$ file chimera_outer
chimera_outer: ELF 32-bit MSB executable, PowerPC or cisco 4500, version 1 (SYSV),
statically linked, BuildID[sha1]=ca50a0e0..., for GNU/Linux 3.2.0, not stripped
Four facts that reshape the whole job:
-
ELF 32-bit PowerPC, big-endian (
MSB): you probably don't have a PPC machine. Doesn't matter, static analysis doesn't need one. - statically linked: no PLT or dynamic linking to make life easier, but also no external dependencies to chase. Everything is inside.
-
not stripped: the symbols are still there. In a CTF that's gold, and here it's more than gold, it's the treasure map. - ~1.5 MB: it's a big target. Suspect embedded code or a huge static libc.
Looking at the symbols, the map shows up:
$ nm chimera_outer | grep -E '_binary_inner_bin|pocket_chimera_inner_main|g_expected_hash|g_flag_enc|g_user_key$'
1016f624 D _binary_inner_bin_end
000bf60c A _binary_inner_bin_size
100b0018 D _binary_inner_bin_start
1017095c b g_expected_hash
1017097c b g_flag_enc
1017094c b g_user_key
100006b0 T pocket_chimera_inner_main
Two gifts jump out:
-
_binary_inner_bin_start/_end/_size: this is the classic signature of a binary embedded withobjcopy(objcopy --input binary ...). Someone pasted an entire file inside.data. The_sizevalue (0xbf60c= 783,884 bytes) already gives away its length. -
g_flag_enc,g_expected_hash,g_user_key: the game's globals. An encoded flag, an expected hash and the user key.
And strings confirms the story:
$ strings -a -n 6 chimera_outer | grep -E 'Pocket Chimera|fork storm|/tmp/pocket|pocket-chimera'
/tmp/pocket_chimera_inner
pocket-chimera-session-v1
/tmp/pocket_chimera_session
pocket-chimera-inner-pepper-v1
/tmp/pocket_chimera_out
Pocket Chimera / nested PPC
fork storm engaged
Pocket Chimera / nested PPC is the challenge's signature. The /tmp paths (an "inner", a "session", an "out") show that the program extracts and writes things at runtime. And fork storm engaged warns that it's going to fork. A lot.
Method note: in under a minute I already know three things. It's PowerPC (static analysis rules), there's an embedded binary (the name
inner_binleaves no doubt), and the flag is protected by a key that goes through a hash (g_expected_hash). None of that required running the target.
Splitting off the second layer: the binary inside the binary
The _binary_inner_bin_start symbol points to 0x100b0018, which is a virtual address (VA). To read the bytes from the file, I need to translate VA to file offset. readelf -S shows that .data starts at VA 0x100b0000 and offset 0xb0000. So, for this section, offset = VA - 0x10000000.
With that, extraction fits in three lines of Python:
f = open('chimera_outer', 'rb').read()
size = 0xbf60c # value of _binary_inner_bin_size
open('inner.bin', 'wb').write(f[0xb0018:0xb0018 + size]) # 0x100b0018 - 0x10000000
And the result:
$ file inner.bin
inner.bin: ELF 32-bit MSB executable, PowerPC or cisco 4500, version 1 (SYSV),
statically linked, BuildID[sha1]=f658b780..., for GNU/Linux 3.2.0, not stripped
$ readelf -h inner.bin | grep -E 'Class|Machine|Entry'
Class: ELF32
Machine: PowerPC
Entry point address: 0x100003d8
inner.bin is a second complete, valid PowerPC executable: 783,884 bytes, also not stripped. Notice it carries the same state symbols (g_expected_hash, g_flag_enc, g_user_key), just at different addresses. It's the same heart of the challenge, now in the inner layer.
Address translation trap:
_binary_inner_bin_startis a data symbol (D), and the valuenmshows is its VA (100b0018)._binary_inner_bin_size, on the other hand, is an absolute symbol (A): its "address" is the size itself,0xbf60c. Don't treat_sizeas an address and don't add the two together. And remember:.dataVA -0x10000000= offset. Get that base wrong and you extract a corrupted ELF.
Reading the inner layer's flow with llvm-objdump
With the inner layer split off, it can be disassembled. Since the regular objdump can't handle PowerPC here, I use llvm-objdump:
$ llvm-objdump -d --no-leading-addr inner.bin | grep -A24 '<pocket_chimera_inner_main>:'
<pocket_chimera_inner_main>:
94 21 fe f0 stwu 1, -272(1)
...
83 9e 80 00 lwz 28, -32768(30) ; loads the SESSION_PATH pointer
...
38 80 00 00 li 4, 0 ; flags = O_RDONLY
48 02 1a 99 bl 0x10022010 <open> ; open("/tmp/pocket_chimera_session", 0)
7c 7d 1b 79 mr. 29, 3
41 80 00 58 bt 0, ... ; bail out on failure
38 a0 00 30 li 5, 48 ; count = 48
48 02 1c 29 bl 0x100221c0 <read> ; read(fd, buf, 48)
The picture emerges: the inner layer opens /tmp/pocket_chimera_session and reads up to 48 bytes. In other words, the second layer depends on a session file that the first layer writes. The pieces fit: the outer layer generates the session, extracts the inner layer and kicks off the "fork storm".
Everything so far is static. But I still don't know how the key is derived. For that I don't need to follow 200 PPC instructions. I need to find the data.
The static crypto chain: everything at the right addresses
The magic lives in .rodata. Reading the bytes straight from the file (same VA to offset translation), I pull out the five ingredients:
| Data | VA | Content |
|---|---|---|
seed (16 B) |
0x10074b78 |
d9e4117a79175ddd4dded9073d27cde6 |
tag (26 B) |
0x10074b8c |
\x00pocket-chimera-session-v1 |
blob (72 B) |
0x10074bc4 |
2653c31d8632cca4... (RC4) |
pepper (31 B) |
0x10074c2c |
\x00pocket-chimera-inner-pepper-v1 |
ALPHABET (39 B) |
0x10074cb4 |
abcdefghijklmnopqrstuvwxyz0123456789!_- |
The names (session-v1, inner-pepper-v1) tell the rest. Key derivation is a hash chain that I rebuild in Python:
import hashlib
def rc4(k, d):
S = list(range(256)); j = 0
for i in range(256):
j = (j + S[i] + k[i % len(k)]) % 256; S[i], S[j] = S[j], S[i]
i = j = 0; o = bytearray()
for c in d:
i = (i + 1) % 256; j = (j + S[i]) % 256; S[i], S[j] = S[j], S[i]
o.append(c ^ S[(S[i] + S[j]) % 256])
return bytes(o)
sess = hashlib.sha256(seed + tag).digest() # session derived from the fixed seed
key = hashlib.sha256(sess + pep).digest() # master key = hash(session + pepper)
dec = rc4(key, blob) # unlocks the .rodata blob
expected = dec[:32] # 32 bytes = a hash
enc = dec[32:] # 40 bytes = the encrypted flag
RC4 unlocks the blob, and the result splits into two very clean halves: 32 bytes (expected) and 40 bytes (enc). Forty bytes is exactly the length of a flag with a domain, so enc is, most likely, the encrypted flag.
All that's missing is the RC4 key for enc. And this is where the challenge hands over its trick.
The offline oracle: expected is the key's hash
The symbol name g_expected_hash finally explains everything. The 32 bytes that came out of the blob are the SHA-256 of a user key. And the 39-character ALPHABET (a-z, 0-9, !, _, -) reveals the search space.
I don't need to guess the structure. I test the hypothesis that expected == sha256(user_key) and run an offline brute force over the first 500,000 candidates (8 characters, generated in base-39 from a counter). Each candidate is just one sha256 and one comparison, so it runs in seconds without touching the target:
AL = b'abcdefghijklmnopqrstuvwxyz0123456789!_-'
for v in range(500000): # 500k candidates
s, x = [], v
for _ in range(8):
s.append(AL[x % 39]); x //= 39
k = bytes(s[::-1]) # 8 bytes, base-39
if hashlib.sha256(k).digest() == expected:
print('KEY', k, rc4(k, enc)); break
And it hits:
KEY b'aaaaihqn' b'n3st3d_ppc_ch1mera_k3y!@malwarespace.org'
The key is aaaaihqn, and rc4(key, enc) returns the flag in plaintext. I confirmed that sha256('aaaaihqn') matches the extracted expected value exactly.
Method note: look at what didn't happen. I didn't run the binary, didn't use a debugger, didn't debug PowerPC. The key came from an offline oracle the challenge itself embedded: it stored the key's hash instead of the key. That turns recovery into brute force, and the small search space is the real design flaw. The full space of 8 characters over 39 symbols is about 42 bits, but the real key starts with
aaaaand falls within the first 500,000 candidates. Far too small to protect a real system, trivial in a CTF.
Dynamic validation (optional): confirm without depending on it
So far the analysis has been 100% static, and the flag is already in hand. But it's worth confirming the deductions by running the target under emulation, precisely to prove that the static reading matches runtime. qemu-ppc-static does that on an x86 machine:
$ qemu-ppc-static ./chimera_outer
Pocket Chimera / nested PPC
fork storm engaged
The program runs, prints its signature and extracts to /tmp exactly what I predicted:
$ ls -la /tmp/pocket_chimera_*
-rwxr-xr-x 1 ... 765.5K /tmp/pocket_chimera_inner
-rw------- 1 ... 32 /tmp/pocket_chimera_session
Two confirmations, and they're conclusive.
1. The inner layer extracted at runtime is byte-for-byte identical to the one I split off statically:
$ cmp /tmp/pocket_chimera_inner inner.bin && echo IDENTICAL
IDENTICAL
The layer the binary writes to /tmp is exactly the 783,884 bytes I cut out of .data earlier. The static extraction was correct down to the last byte.
2. The runtime session file is the hash I had derived on paper:
sess == sha256(seed + tag) # -> True
# 6757badd95874c1bb649d1f9a28759fada68088e40e81a44b60ec7b7878dd736
The 32 bytes in /tmp/pocket_chimera_session are identical to the sess I computed with sha256(seed + tag). The derivation chain I rebuilt isn't a guess, it's literally what the program executes.
Notice a subtle point: the seed is a fixed constant in .rodata, so the "session" is always the same. The name suggests randomness, but in practice it's deterministic. One more detail that static analysis made clear before anything ran.
Method note: I used
qemu-ppc-staticat the very end, only to validate. That's the point. On a target whose architecture you don't control, or one that would be risky to execute (real malware), dynamic validation is a luxury, not a necessity. The main path is static; runtime comes in as a sanity check.
Flag
n3st3d_ppc_ch1mera_k3y!@malwarespace.org
RC4 key recovered by offline brute force: aaaaihqn.
The same result, in Python
The full solver I keep in my write-ups repository:
#!/usr/bin/env python3
import hashlib
f = open('chimera_outer', 'rb').read()
def rd(va, n): return f[va - 0x10000000: va - 0x10000000 + n]
seed = rd(0x10074b78, 16); tag = rd(0x10074b8c, 26)
pep = rd(0x10074c2c, 31); blob = rd(0x10074bc4, 0x48)
def rc4(k, d):
S = list(range(256)); j = 0
for i in range(256):
j = (j + S[i] + k[i % len(k)]) % 256; S[i], S[j] = S[j], S[i]
i = j = 0; o = bytearray()
for c in d:
i = (i + 1) % 256; j = (j + S[i]) % 256; S[i], S[j] = S[j], S[i]
o.append(c ^ S[(S[i] + S[j]) % 256])
return bytes(o)
sess = hashlib.sha256(seed + tag).digest()
key = hashlib.sha256(sess + pep).digest()
dec = rc4(key, blob)
expected = dec[:32]; enc = dec[32:]
AL = b'abcdefghijklmnopqrstuvwxyz0123456789!_-'
for v in range(500000):
s, x = [], v
for _ in range(8):
s.append(AL[x % 39]); x //= 39
k = bytes(s[::-1])
if hashlib.sha256(k).digest() == expected:
print('KEY', k); print('FLAG', rc4(k, enc).decode()); break
# KEY b'aaaaihqn'
# FLAG n3st3d_ppc_ch1mera_k3y!@malwarespace.org
What this chimera teaches
chimera_outer looks scary: PowerPC, a 1.5 MB binary, one executable inside another, a "fork storm". But the kindling method survives intact, with a few adjustments in discipline:
-
An exotic architecture is no excuse.
filesays "PowerPC", and the answer is: analyze statically. You don't need a PPC machine to read a PPC ELF. -
not stripped+ descriptive symbols are the treasure map._binary_inner_bin_start,g_expected_hash,g_flag_enc: those names alone sketch the entire challenge before any disassembly. -
Embedded binaries have a signature. The
_binary_*_start/_end/_sizesymbols areobjcopy's fingerprint. When you see them, extract the layer withoffset = VA - section_base. Here,0x100b0018becomes0xb0018, withsize = 0xbf60c. -
A challenge can embed its own oracle. Here,
expected_hashis the SHA-256 of the key. Turn crypto into a search problem when the space is small, and a 39-characterALPHABETgives that away. -
Don't follow 200 instructions; find the data. Instead of walking the PPC loop, I went straight to
.rodataand rebuilt theseed → sess → key → RC4chain at the right addresses. -
Validate, but don't depend on runtime.
qemu-ppc-staticconfirmed it (identical inner layer, session equal to the derived hash), but only after static analysis had already done the work. Running malware is confirmation, not method.
The workflow is the same as always (recon, split layers, isolate the transformation, invert, validate), just with two layers and a strange architecture in the middle. The method is what scales. The tooling is what's left over.
Happy studying and happy hacking!
Challenge: **chimera_outer, from the **Malware Space Village. 32-bit big-endian PowerPC ELF, static, not stripped, with a second PPC executable embedded in .data. Protection: RC4 with a key derived from SHA256(SHA256(seed‖tag)‖pepper); user key recovered by offline brute force (500,000 candidates) against the embedded hash. Tools: file, readelf, nm, strings, llvm-objdump, qemu-ppc-static (validation) and Python 3.
Top comments (0)