
Pocket CTF Box: Overloaded Shores - Reverse Engineering Writeup Report
Case ID: BB-RE-2026-0813
Classification: Reverse Engineering - Obfuscated State Machine with Anti-Debug
Author (challenge): prap
Evidence: chall (stripped x86-64 ELF PIE binary)
Date of Analysis: 2026-08-13
Challenge Link: https://learn.hacklido.com/challenges/overloaded-shores
1. Executive Summary
The binary implements a state machine where each state is a function pointer, the active state is stored obfuscated (XOR 0x55), and states advance in a loop with a sleep(1) between iterations. On startup the program randomly enters either the legitimate input-prompt path or one of several decoy paths that print fake-looking flags, matching the challenge’s warning that “many found flags… but none were real.” The binary also calls ptrace(PTRACE_TRACEME, ...) to detect an attached debugger and exit early, so static analysis rather than live debugging was used to recover the real flag.
Verdict: Recovered flag is HackCTF{G0@_b3@cH_Ov#rL0ad3d}.
2. Tools Used
| Tool | Purpose |
file | Identify binary type/architecture |
strings | Locate embedded plaintext (decoy flags, prompts) |
objdump -d -M intel | Disassemble the binary (AT&T tools weren’t needed; Intel syntax used throughout) |
objdump -s | Dump raw bytes of .data / .rodata for manual decoding |
readelf -S | Confirm section layout and addresses |
| Python 3 | Manual XOR decoding of the obfuscated flag bytes, and dynamic verification |
3. Methodology - Step by Step
Step 1: Identify the binary and get a first read on strings
file chall
chall: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, stripped
strings -n 6 chall
Relevant output:
HackCTF{once_more}
HackCTF{try_harder}
HackCTF{not_this_one}
Enter key:
Debugger detected!
Three flag-shaped strings are visible immediately, but per the challenge description (“Some found flags… but none were real”), these are expected to be decoys. No real flag string appears in plaintext, meaning it must be constructed or decoded at runtime rather than stored as a literal string.
Step 2: Disassemble and map out the control flow
objdump -d -M intel chall > chall.asm
The binary is small (14 KB) with only a handful of real functions once PLT stubs and glibc startup boilerplate are filtered out. Walking the disassembly from main (at 0x1420) reveals the structure the hints describe:
// simplified from the disassembly
srand(time(0));
if (ptrace(PTRACE_TRACEME, 0, 1, 0) == -1) {
puts("Debugger detected!");
exit(1);
}
// build a jump table of 6 function pointers at 0x40e0..0x4108
state_table[0] = &state_0;
state_table[1] = &state_1_prompt;
state_table[2] = &state_2_check_goa;
state_table[3] = &state_3_reveal_real_flag;
state_table[4] = &state_4_reveal_decoy_flag;
state_table[5] = &state_5_exit;
while (1) {
int state = raw_state_global ^ 0x55; // matches Hint 1 exactly
if (state >= 0 && state <= 5) {
state_table[state]();
} else {
// raw_state_global starts at 0 -> state = 0x55 (85), out of range
int r = (rand() % 3) * 2; // randomly picks 0, 2, or 4
raw_state_global = r ^ 0x55;
}
sleep(1); // matches Hint 1 exactly
}
This confirms both hints directly: the state is XOR’d with 0x55, and each iteration ends with sleep(1).
Step 3: Decode each state function
Walking each function pointed to by the jump table:
State 0 (0x12a3) - trivial: sets raw_state_global = 0x54. Since 0x54 ^ 0x55 = 1, this always advances to state 1.
State 1 (0x12b4) - the prompt:
printf("Enter key: ");
scanf("%31s", input_buffer); // input_buffer lives at 0x4120
raw_state_global = 0x57; // 0x57 ^ 0x55 = 2, always advances to state 2
State 2 (0x1336) - the validation check:
char expected[4] = "emc"; // stored as the raw bytes 65 6d 63 00 at [rbp-0xc]
int ok = 1;
for (int i = 0; i <= 2; i++) {
if ((input_buffer[i] ^ 2) != expected[i]) ok = 0;
}
raw_state_global = ok ? 0x56 : 0x51;
// 0x56 ^ 0x55 = 3 (success -> state 3)
// 0x51 ^ 0x55 = 4 (failure -> state 4)
Solving the comparison by hand: the input must satisfy input[i] ^ 2 == "emc"[i], so:
'e' (0x65) ^ 2 = 0x67 = 'g'
'm' (0x6d) ^ 2 = 0x6f = 'o'
'c' (0x63) ^ 2 = 0x61 = 'a'
The first three characters of the input must be "goa", exactly matching Hint 2 (“input must start with ‘goa’”, tying back to “You arrived in Goa”).
State 3 (0x13a4) - the real flag path: calls a helper function at 0x11d9 (analyzed in Step 4 below), then sets raw_state_global = 0x50 (0x50 ^ 0x55 = 5, advancing to the exit state).
State 4 (0x13ba) - the decoy flag path:
int idx = rand() % 3;
puts(decoy_flags[idx]); // one of the three fake HackCTF{...} strings from Step 1
raw_state_global = 0x50; // also advances to state 5 (exit)
State 5 (0x1412) - calls exit(0).
Step 4: Decode the real flag’s construction (helper at 0x11d9)
This function, called only from the success path (state 3), does the following:
void *buf = mmap(NULL, 0x64, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
memcpy(buf, &secret_bytes, 29); // secret_bytes lives in .data at 0x4070
for (int i = 0; i <= 0x1c; i++) { // 0..28 inclusive = 29 bytes
buf[i] ^= 0x23;
}
buf[29] = 0;
puts(buf);
munmap(buf, 0x64);
The 29 raw bytes are copied directly from the binary’s .data section rather than being computed, so they can be extracted statically without ever running the program:
objdump -s -j .data chall
Contents of section .data:
4070 6b424048 60776558 6413637c 41106340
4080 6b7c6c55 00516f13 42471047 5e000000
Taking the first 29 bytes starting at 0x4070 and XOR-ing each with 0x23:
data_hex = "6b424048607765586413637c411063406b7c6c5500516f134247104 75e" # first 29 bytes
raw = bytes.fromhex(data_hex.replace(" ", ""))
secret = bytes([b ^ 0x23 for b in raw[:29]])
print(secret)
Output:
b'HackCTF{G0@_b3@cH_Ov#rL0ad3d}'
This decodes cleanly to a well-formed flag, and notably contains “Ov#rL0ad3d”, a direct callback to the challenge’s title, “Overloaded Shores”, confirming this is the genuine flag and not another decoy.
Step 5: Dynamic verification
Since the binary is small and its only anti-debug check is a single ptrace(PTRACE_TRACEME) call (which succeeds and does nothing suspicious under a plain, non-debugger run), it can simply be executed directly and fed the required input:
(echo "goaHackCTF"; sleep 6) | ./chall
Output:
Enter key: HackCTF{G0@_b3@cH_Ov#rL0ad3d}
This confirms the statically recovered flag is correct and matches actual program behavior. Note the random decoy branch means a direct run without patience/retries can sometimes print a decoy flag first if the initial random state lands on state 2 or state 4 before state 1 ever prompts, waiting for the “Enter key:” prompt specifically before typing ensures the legitimate path is reached.
4. Consolidated Findings
| Item | Value |
| State obfuscation | Global state XOR’d with 0x55 |
| Loop pacing | sleep(1) between every state transition |
| Required input prefix | goa (derived from an XOR-2 comparison against the string “emc”) |
| Real flag storage | 29 raw bytes in .data at address 0x4070, XOR’d with 0x23 at runtime inside an mmap'd RWX buffer |
| Decoy flags | HackCTF{once_more}, HackCTF{try_harder}, HackCTF{not_this_one} (randomly chosen via rand() % 3 on the failure path) |
| Anti-debug | ptrace(PTRACE_TRACEME, ...) check; exits with “Debugger detected!” if already traced |
| Recovered flag | HackCTF{G0@_b3@cH_Ov#rL0ad3d} |
5. Key Takeaways
- Obfuscating a state variable with a static XOR key does not protect the data the states operate on, the flag bytes were still sitting in
.data in plain (if XOR’d) form and were recoverable with zero execution.
- Anti-debug checks like a single
ptrace(PTRACE_TRACEME) call only block live, in-process debugging; they do nothing to prevent offline static disassembly, which fully solved this challenge without ever needing to bypass the check.
- Decoy strings visible via
strings are a common misdirection technique in reversing challenges; always verify a found flag traces back to real program logic (here, corroborated by both the standalone byte-decode and a live successful run) rather than trusting the first HackCTF{...}-shaped string found.