Reverse Engineering 7-Bit Packing: Perplexed CTF Walkthrough

Challenge Info:

  • Platform: picoCTF 2025 - Perplexed
  • Category: Reverse Engineering
  • Difficulty: Medium
  • Author: skrublawd
  • Techniques: 7-Bit Packing, Memory Overlap, Little Endian

Understanding the Challenge

The Setup: So we've got this program called perplexed. It wants a password. Type the wrong thing, it tells you no. Our job is to figure out how it works so we can find the right password (which is the flag).

What Kind of Challenge Is This? This is what's called a "Crackme". We're not trying to break it or make it crash - the program works fine. The tricky part is that the password isn't just sitting there in plain text. It's hidden behind some custom math and memory tricks.


File Analysis and Reconnaissance

Before we even run anything, let's see what we're dealing with.

File Identification

file perplexed

Result: ELF 64-bit LSB executable, x86-64, not stripped

What this tells us:

  • ELF 64-bit: Standard Linux program for 64-bit systems.
  • Not stripped: This is huge for us. "Stripped" means they removed function names to make it harder to reverse. Since this ISN'T stripped, we can see names like main and check. Saves us hours of work.

Security Protections

checksec --file=perplexed

Result: No PIE

Why we care:

  • No PIE: The program loads at the same spot in memory every time (0x400000). This means main will always be at 0x4012a5. Makes our life easier when matching up addresses between different tools.

Security protections analysis showing the binary has no PIE enabled.


Behavioral Analysis

Before diving into assembly, let's try the easy way first.

Basic Execution

./perplexed
> Enter the password: AAAA
> Wrong :(

Pretty simple - asks for input, tells you if you're wrong.

Dynamic Analysis with ltrace

Sometimes devs get lazy and use strcmp to check passwords. If they did, ltrace would show us the password immediately.

ltrace ./perplexed

What we see:

printf("Enter the password: ")
fgets(...)
strlen(...)
puts("Wrong :(")

Hmm: It checked the length with strlen, but no strcmp or memcmp.

What this means: They wrote their own checking code instead of using standard functions. Can't take the easy way out - gotta dig into the assembly.

Dynamic analysis showing function calls made during program execution.


Static Analysis and Disassembly

Time to crack open objdump and see what's actually happening.

Understanding main()

Looking at main (at 0x4012a5), here's what it does:

  1. Gets your input with fgets.
  2. Calls a function at 0x401156 called check.
  3. If check returns 1, you win.

So all the magic is in that check function.

Deconstructing the check() Function

This function has three parts:

Part 1: Length Validation

call   401040 <strlen@plt>
cmp    rax, 0x1b
je     ...

First thing it does - your input better be 27 bytes (0x1b in hex). Wrong length? Instant fail.

Part 2: Key Loading and Memory Overlap

The function loads three big hex numbers into memory. Think of memory like a row of boxes, numbered starting at 0.

The numbers:

  1. Key A: 0x617b2375f81ea7e1 (8 bytes)
  2. Key B: 0xd269df5b5afc9db9 (8 bytes)
  3. Key C: 0xf467edf4ed1bfed2 (8 bytes)

Quick Detour: Little Endian Intel CPUs store numbers backwards byte-by-byte. This is called Little Endian.

  • You write: 0x12345678
  • CPU stores: 78 56 34 12

So when we see 0x617b..., the CPU actually writes the last byte e1 first.

How It Puts Them in Memory:

  • Key A: Goes in boxes 0-7.
  • Key B: Goes in boxes 8-15.
  • Key C: Goes in boxes 15-22.

Wait, What? The Overlap! See the problem?

  • Key B writes to box 15.
  • Key C ALSO starts at box 15.

Key C overwrites the last byte of Key B. This is on purpose - it's a trick to confuse people reversing the code. If you just looked at Key B alone, you'd get the wrong answer. Box 15 actually belongs to Key C now.

The Real Layout:

  • Boxes 0-7: All of Key A.
  • Boxes 8-14: First 7 bytes of Key B.
  • Box 15: First byte of Key C (overwrites Key B's last byte!).
  • Boxes 16-22: Rest of Key C.

Assembly code showing the three key constants being loaded and the memory overlap trick.

Part 3: The 7-Bit Packing Comparison

The program doesn't check your password normally. It uses something called 7-bit packing (like old text messages used to do).

Here's the idea:

  • Normal ASCII letters use 8 bits each: 0xxxxxxx.
  • That first bit (MSB) is almost always 0.
  • The other 7 bits are the actual letter.

The program goes, "Why waste space on those zeros?"

So instead of comparing 8 bits at a time:

  1. Takes your input.
  2. Strips off the leading 0 from each letter.
  3. Squishes all the 7-bit pieces together into one long stream.
  4. Compares that against the memory from Part 2.

Example: Say you typed "AB":

  • Normal: (16 bits, two wasted zeros)
  • Squished: aaaaaaa + bbbbbbb (14 bits, no waste)

To crack it, we just reverse this - take the raw memory and split it into 7-bit chunks.


Exploitation and Password Extraction

Now we just reverse their logic. Build the key as bits, chop it into 7-bit pieces.

Attack Strategy

  1. Grab those hex numbers from the assembly.
  2. Handle Little Endian with Python's struct.
  3. Fix the overlap at byte 15.
  4. Decode the bits:
    • Turn memory into a long string of 1s and 0s.
    • Slice it every 7 bits.
    • Turn each 7-bit chunk into a letter.

Implementation Details

This isn't magic - each line matches what we saw in assembly:

Why struct.pack('<Q', ...)?

  • Problem: Assembly moves huge numbers into registers. When the CPU stores them in RAM, it does Little Endian (backwards).
  • Fix: struct.pack('<Q', number) = "Take this 8-byte number and pack it Little Endian style." Gives us the exact bytes as they appear in memory.

Why c2_bytes[:7]? (The Overlap Fix)

  • Problem: Key C starts at byte 15, overwriting Key B's last byte.
  • Fix: Only take 7 bytes from Key B (c2_bytes[:7]), then add Key C. This recreates the "corrupted" memory state the program uses.

Why bits? (The 7-bit Thing)

  • Problem: Program doesn't compare bytes (8 bits). It compares a stream where each character is 7 bits.
  • Fix: Turn memory into 1s and 0s, then slice by 7 instead of 8. Convert each 7-bit chunk to a character.

The Solution Script

import struct

# 1. Grab the numbers from assembly
c1 = 0x617b2375f81ea7e1
c2 = 0xd269df5b5afc9db9
c3 = 0xf467edf4ed1bfed2

# 2. Pack as Little Endian
# '<Q' = Little Endian (<), 8-byte unsigned int (Q)
c1_bytes = struct.pack("<Q", c1)
c2_bytes = struct.pack("<Q", c2)
c3_bytes = struct.pack("<Q", c3)

# 3. Handle the overlap
# c1: bytes 0-7
# c2: bytes 8-14 (only first 7, since c3 overwrites byte 15)
# c3: bytes 15-22
secret = c1_bytes + c2_bytes[:7] + c3_bytes

# 4. Decode the bits
# Turn bytes into a giant string of 1s and 0s
bits = "".join(f"{byte:08b}" for byte in secret)

# Flag is 27 chars. 27 * 7 = 189 bits
bits = bits[:189]

password = ""
# Read 7 bits at a time
for i in range(27):
    chunk = bits[i*7 : (i+1)*7]
    password += chr(int(chunk, 2))

print(f"Flag: {password}")

Flag Capture

Run solve.py and boom:

Flag: picoCTF{0n3_bi7_4t_a_7im3}

Successful script execution showing the extracted flag.


Key Takeaways

Reverse Engineering Techniques

  • Unstripped binaries preserve function names for easier analysis
  • No PIE means consistent memory addresses across executions
  • ltrace reveals library function calls and behavior patterns
  • Custom password verification requires disassembly analysis

Obfuscation Methods

  • 7-bit packing reduces storage but adds complexity
  • Memory overlap tricks confuse static analysis
  • Little Endian byte ordering must be handled correctly
  • Bit-level operations require precise reconstruction

Attack Methodology

  1. Identify file type and security protections
  2. Observe runtime behavior with dynamic analysis
  3. Disassemble to understand internal logic
  4. Reconstruct memory layout accounting for endianness
  5. Reverse custom encoding schemes (7-bit packing)
  6. Extract password from decoded data