iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0

前言

「使用者貼一個網址,伺服器幫他抓下來」。這個功能是這個產品的核心,也是整個專案裡最危險的一支程式。

發請求的是你的伺服器。瀏覽器在使用者的網路裡,碰不到你的機器;伺服器在你的網路裡,碰得到一堆不該給外人看的東西。使用者只要貼一個指向這些地方的網址,你的伺服器就會替他走進去,還把看到的東西帶回來給他。

這個攻擊叫 SSRF,Server-Side Request Forgery,伺服器端請求偽造。名字裡的偽造指的是位置:請求是從你的伺服器、你的網段發出去的,攻擊者只是叫它去打。他不需要打進你的機器,只要叫你的機器去打你的內網

四層防護由上到下:驗位址、逐跳驗轉址、連線時釘住 IP、無頭瀏覽器走過濾代理

由上到下四層:驗位址、逐跳驗轉址、連線時釘住 IP、瀏覽器走過濾代理。這一篇由上往下走一遍。最下面那道大小與逾時,是同一支程式另外管的。

為什麼危險

你的伺服器在雲端上,跟資料庫在同一個私有網段。它還可能碰得到雲端供應商的 metadata 服務,在很多平台上,那個端點會吐出這台機器的憑證。

現在使用者送來這些網址:

http://localhost:5432          你的資料庫
http://10.0.0.5/admin          內網的管理介面
http://169.254.169.254/...     雲端的 metadata
http://[::1]:6379              IPv6 的本機

如果你只是拿到網址就 fetch,這些全都會打出去,抓回來的內容會存進 raw/

第一層:驗證位址

第一步是解析網址、擋掉不該碰的目的地:

const V4_BLOCKS: [string, number][] = [
  ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
  ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
  ['224.0.0.0', 3], // 224/4 multicast + 240/4 reserved + 255.255.255.255
];

出處:src/net-guard.ts:16-21

IPv6 那邊另外處理 ::1fc00::/7fe80::/10,還有三種會把 IPv4 藏進 IPv6 的寫法:::ffff:0:0/96(映射)、64:ff9b::/96(NAT64)、2002::/16(6to4)。漏掉後面這三種,前面十三條就等於沒寫,攻擊者用 http://[::ffff:127.0.0.1]/ 一樣打得到本機。

100.64.0.0/10 是 CGNAT,某些雲的內網用這一段;169.254.0.0/16 是 link-local,雲端 metadata 就住在這裡。

還要擋協定:只允許 http 和 https,file://gopher:// 這些一律拒絕。主機名也有黑名單,像 metadata.google.internal

第二層:每一跳都驗

只驗第一個網址是不夠的,因為轉址。使用者給你 https://某個看起來正常的網站/redirect?to=http://169.254.169.254/,那個網站回一個 302,你的 HTTP 客戶端預設會自動跟過去。你只驗了第一個網址。

所以我關掉自動跟隨轉址,改成自己一跳一跳走:

let url = await assertPublicHttpUrl(raw, opts);
for (let hop = 0; ; hop++) {
  const res = await undiciFetch(url.href, {
    headers: opts.headers, redirect: 'manual', signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
    dispatcher: opts.allowPrivate ? openAgent : strictAgent,
  });
  if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
    await res.body?.cancel().catch(() => {});
    if (hop >= maxRedirects) throw new NoteError('BAD_PATH', { 'zh-TW': '轉址次數過多', en: 'Too many redirects' });
    url = await assertPublicHttpUrl(new URL(res.headers.get('location')!, url).href, opts);
    continue;
  }
  …

出處:src/net-guard.ts:99-113

redirect: 'manual' 是整段的前提,每一跳的 location 都要再過一次 assertPublicHttpUrl。跳太多次就中止,而且轉址回應的 body 要記得 cancel(),不然連線會掛著。

審查我這段程式的人就是用這招測的:拿一個公開的轉址服務,指向 127.0.0.1。第一版被打穿了。

第三層:釘住 IP

還有一種攻擊叫 DNS rebinding。流程是這樣:你解析網域拿到一個公開 IP,驗證通過;接著你發出真正的請求,而在這兩個動作之間,攻擊者控制的 DNS 把同一個網域改指向 127.0.0.1。你驗的是 A,連的是 B。

做法是接管 HTTP 客戶端自己的 DNS 查詢,在真正要撥接的那一刻再解析一次,只把非私有的位址交出去:

const strictAgent = new Agent({
  connect: {
    lookup: ((hostname, options, callback) => {
      dnsLookup(hostname, { all: true }).then(addrs => {
        const ok = addrs.filter(a => !isPrivateIp(a.address));
        if (!ok.length) { callback(Object.assign(new Error(`不允許連線到內部位址:${hostname}`), { code: 'EPRIVATE' }), []); return; }
        callback(null, ok.map(a => ({ address: a.address, family: a.family })));
      }).catch(err => callback(err, []));
    }) as never,
  },
});

出處:src/net-guard.ts:79-92

關鍵在最後那行 callback 只回傳過濾後的位址。這個 lookup 是連線時才呼叫的,所以它看到的就是真的要撥出去的那個 IP,中間就算 DNS 被換掉,換過去的私有位址也會在這裡被濾掉。

第四層:瀏覽器走過濾代理

之前我們已經講過,無頭瀏覽器會自己去載入網頁裡的所有子資源,那些請求不會經過前面的 safeFetch。啟動 Chromium 時指定一個本機的代理,瀏覽器就沒有直接連外的能力,所有流量都必須經過它。代理對每一個請求做解析、驗證,並連到解析出來的那個 IP;轉址是瀏覽器再發一次,所以每一跳也會再過代理。圖片、字型、XHR、WebSocket 全部包含在內。

我準備了兩個代理,一個嚴格版、一個允許私有網段,允許私有網段的那個只有測試用的 fixture 伺服器會走。

還要防大小

還有兩個不是 SSRF 但同一支程式該處理的事:回應大小上限(我設五 MB,超過就中斷串流,不是讀完再檢查)跟逾時(十五秒)。沒有這兩個,一個永遠不結束的回應就能吃掉你的記憶體。

小結

攻擊者打不進你的內網,就貼一個網址,叫你的伺服器幫他去抓資料庫或雲端憑證,這就是 SSRF。我連出去之前先看要去哪裡,是內網就停,他什麼都拿不到。使用者貼一般網站,該抓還是抓。


上一篇
Day 13 - 抓取網頁來源,匯入知識庫
系列文
為你自己蓋一座會複利的知識庫——WikiBrain14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言