DEV Community

Cover image for From PowerPC file to flag: taking apart chimera, the binary that wraps another binary (Malware Space Village)
Higor Diego
Higor Diego

Posted on

From PowerPC file to flag: taking apart chimera, the binary that wraps another binary (Malware Space Village)

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 /tmp and 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 objdump here complains can't disassemble for architecture UNKNOWN. For PowerPC, llvm-objdump works out of the box. And to run a PPC binary on an x86 machine there's qemu-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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Two gifts jump out:

  • _binary_inner_bin_start / _end / _size: this is the classic signature of a binary embedded with objcopy (objcopy --input binary ...). Someone pasted an entire file inside .data. The _size value (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
Enter fullscreen mode Exit fullscreen mode

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_bin leaves 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_start is a data symbol (D), and the value nm shows 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 _size as an address and don't add the two together. And remember: .data VA - 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And it hits:

KEY b'aaaaihqn' b'n3st3d_ppc_ch1mera_k3y!@malwarespace.org'
Enter fullscreen mode Exit fullscreen mode

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 aaaa and 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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-static at 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. An exotic architecture is no excuse. file says "PowerPC", and the answer is: analyze statically. You don't need a PPC machine to read a PPC ELF.
  2. 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.
  3. Embedded binaries have a signature. The _binary_*_start/_end/_size symbols are objcopy's fingerprint. When you see them, extract the layer with offset = VA - section_base. Here, 0x100b0018 becomes 0xb0018, with size = 0xbf60c.
  4. A challenge can embed its own oracle. Here, expected_hash is the SHA-256 of the key. Turn crypto into a search problem when the space is small, and a 39-character ALPHABET gives that away.
  5. Don't follow 200 instructions; find the data. Instead of walking the PPC loop, I went straight to .rodata and rebuilt the seed → sess → key → RC4 chain at the right addresses.
  6. Validate, but don't depend on runtime. qemu-ppc-static confirmed 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)