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
mainandcheck. 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 meansmainwill always be at0x4012a5. 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:
- Gets your input with
fgets. - Calls a function at
0x401156calledcheck. - If
checkreturns 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:
- Key A:
0x617b2375f81ea7e1(8 bytes) - Key B:
0xd269df5b5afc9db9(8 bytes) - 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:
- Takes your input.
- Strips off the leading 0 from each letter.
- Squishes all the 7-bit pieces together into one long stream.
- 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
- Grab those hex numbers from the assembly.
- Handle Little Endian with Python's
struct. - Fix the overlap at byte 15.
- 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
- Identify file type and security protections
- Observe runtime behavior with dynamic analysis
- Disassemble to understand internal logic
- Reconstruct memory layout accounting for endianness
- Reverse custom encoding schemes (7-bit packing)
- Extract password from decoded data