ここで書く内容は blackhat USA 2017 で Orange Tsai 氏が発表した 「A New Era of SSRF - Exploiting URL Parser in Trending Programming Languages!」 で取り上げられていた内容を自分が改めて理解するために書き残したメモです。


概要

var base = "https://example.com/article/";
var path = req.query.path;
if (path.indexOf("..") == -1) {
  http.get(base + path, callback);
}

上記のようなコードのとき、 /?path=../passwd のようにアクセスして http.get() の処理に入り、 /article/../passwd を取得することはできるだろうか。
一見 if 文は false になるため不可能そうに見えるが、実は true になることがある。

true とするには /?path=ĮĮ/passwd というURIにアクセスすることで可能となる。
ここでは、なぜこれでバイパスできてしまうのか、ということを書いていく。

ちなみに今回検証で確認できたのは Node.js 4.9.1 Argon ( ubuntu 16.04 LTS で未だに入る ) 。
v10.13.0 Dubnium では確認できなかった(ちゃんと文字コード関係のエラーが出るようになっていた)。
どのバージョンから再現できないのかまでは調べていないので、知っている人がいたら教えてください。

サロゲートペア

JS の内部表現は UTF-16 なのだが、これが Node.js でどのように扱われるのか。
答えは JavaScript’s internal character encoding: UCS-2 or UTF-16? に書かれている。

💩 は UTF-16 ではサロゲートペアで \uD83D\uDCA9 の2つに分離される。

> '💩'.split('').map( (c) => c.codePointAt().toString(16) )
[ 'd83d', 'dca9' ]

このサロゲートペアはどのように計算されているのだろうか。
上記記事でも参照されているが、 Section 3.7 of The Unicode Standard 3.0 で定義されている。

0xFFFF よりコードポイントが大きい C は次の計算式によって2つのコードポイント HL に分離される。

H = Math.floor((C - 0x10000) / 0x400) + 0xD800
L = (C - 0x10000) % 0x400 + 0xDC00

つまり、2つのコードポイント HL を用いて表現されるユニコードのコードポイント C は次のような式となる。

C = (H - 0xD800) * 0x400 + L - 0xDC00 + 0x10000

💩 で試してみよう。

> C = '💩'.codePointAt()
128169
> H = Math.floor((C - 0x10000) / 0x400) + 0xD800
55357
> H.toString(16)
'd83d'
> L = (C - 0x10000) % 0x400 + 0xDC00
56489
> L.toString(16)
'dca9'
> C = (H - 0xD800) * 0x400 + L - 0xDC00 + 0x1000
128169
> String.fromCodePoint(C)
'💩'

さて、ではここで Node.js の http モジュールがどのようにサロゲートペアを扱うか見てみよう。

var base = "http://127.0.0.1:8000/article/";
var path = req.query.path; // req.query.path = "💩"
if (path.indexOf("..") == -1) {
  http.get(base + path, callback);
}

req.query.path💩 が含まれていた場合、 http.get() の送信先URIはどうなるだろうか。

$ python -m http.server 
127.0.0.1 - - [13/Nov/2018 23:26:35] "GET /=© HTTP/1.1" 404 -

上記のように 💩 にはならず、 になる。

これはサロゲートペアそれぞれの最下位バイトのみ扱っているからだ。

// 💩 = \ud83d \udca9
> String.fromCodePoint(parseInt('3d', 16))
'='
> String.fromCodePoint(parseInt('a9', 16))
'©'

SSRFとディレクトリトラバーサルに活用する

最下位バイトのみ扱うことがわかったのでこれを攻撃に活かそう。
上記コードのように特定のURLのパスに送信する処理がある場合、ディレクトリトラバーサルへつながることも考えられる。

$ ls
article/   secret.txt
$ ls article/
1.html
2.html
$ python -m http.server 

(お粗末だが、あくまで一例なので…)

挙げている例の場合、 var base = "https://example.com/article/" と指定しているのと path.index("..") == -1 があるため secret.txt は取得できないように見える。

しかし http モジュールが各サロゲートペアの最下位バイトのみを扱うことを利用することで path.index("..") を回避して .. を送り込むことができる。

そのためには、最下位バイトが . ( 2e ) のユニコード文字列を探してくればよい。

上記サイトから探すのが楽。
最初に引っかかるのが Į ( LATIN CAPITAL LETTER I WITH OGONEK (U+012E) ) だ。

/ĮĮ/secret.txt にアクセスすると、 path.indexOf("..") では ĮĮ のままなので通過する。
そして http.get() の段階で .. に変換されるため ../secret.txt へアクセス可能となる。

Ref

node  unicode  ssrf