1. Introduction
본문서는 2025 WHITEHAT 예선에 참가한 팀 헤일메리의 문제별 write-up으로, AI Sommelier와 Leakage Investigation 두 문제를 다룬다. (다른 문제는 내가 안풀어서 언젠간 시간나면 적을듯)
2. AI Sommelier
2.1 Overview
문제 서버는 LLaMA 3.2가 생성한 문장 10개와 Gemma 3가 생성한 문장 10개, 총 20개의 텍스트를 무작위로 제공한다. 참가자는 각 텍스트의 생성 모델을 판별해 제출하면 플래그를 획득한다.
기본 가정은 언어 모델이 스스로 생성했을 법한 텍스트에 대해 상대적으로 더 높은 로그우도(log-likelihood)를 부여한다는 것이다. 후보 모델을 직접 구동하고 입력 텍스트의 logprobs를 관측하면 모델 기원 분류(model attribution)가 가능하다.
2.2 Methodology
입력 시퀀스 x = (t1, ..., tn)에 대해 모델 M이 부여하는 로그우도 합은 각 토큰의 조건부 로그확률을 더해서 구했다. 길이에 따른 편향을 줄이기 위해 전체 합을 토큰 수로 나눈 평균 로그우도(mean_logprob)를 사용했고, 근소한 차이를 안정적으로 비교하려고 전체 로그우도를 입력 바이트 길이와 ln 2로 나눈 bit-per-byte(bpb) 지표도 함께 계산했다. 두 값 모두 높을수록(또는 bpb는 낮을수록) 해당 모델이 텍스트를 더 그럴듯하게 본다는 의미다.
실제 환경은 NVIDIA TESLA T10 16GB였는데, Gemma 3의 bf16 요구사항 때문에 로컬 서빙이 어려웠다. 따라서 LLaMA 3.2만 구동하여 20개 텍스트 모두에 대한 mean_logprob을 계산한 뒤, 가장 높게 나온 상위 10개를 LLaMA 3.2 산출물로, 나머지를 Gemma 3 산출물로 가정해 제출했다.
2.3 Implementation
Serving
LLaMA 3.2는 vLLM(OpenAI 호환)으로 서빙했다.
uv run vllm serve meta-llama/Llama-3.2-3B-Instruct \
--served-model-name llama3.2 \
--port 7001 \
--dtype float16 \
--max-model-len 4096 \
--max-num-seqs 1 \
--max-num-batched-tokens 256 \
--gpu-memory-utilization 0.80
Scoring
문제 서버에서 텍스트를 수신한 뒤 chat/completions 엔드포인트(필요 시 completions)로 echo=true, max_tokens=0, logprobs=true 옵션을 주어 토큰 로그확률을 회수하고 mean_logprob/bpb를 계산했다.
import json
import math
import os
import time
import requests
CHALLENGE_BASE = os.getenv("CHALLENGE_BASE", "http://<redacted>:19999")
VLLM_BASE = os.getenv("VLLM_BASE_URL", "http://127.0.0.1:7001")
LLAMA, GEMMA, TOP_K = "llama3.2", "gemma3", 10
def wait_llama():
for _ in range(240):
r = requests.get(f"{VLLM_BASE}/v1/models", timeout=5)
models = [m.get("id") or m.get("name") for m in r.json().get("data", [])]
if LLAMA in models:
return
time.sleep(0.5)
raise RuntimeError("llama3.2 not ready")
def fetch_challenge():
r = requests.get(f"{CHALLENGE_BASE}/challenge/new", timeout=30)
r.raise_for_status()
js = r.json()
return js["id"], js["texts"]
def submit_answers(cid, answers):
r = requests.post(
f"{CHALLENGE_BASE}/challenge/{cid}",
json={"answers": answers},
timeout=60,
)
r.raise_for_status()
return r.json()
def score_mean_bpb(text):
payload = {
"model": LLAMA,
"messages": [{"role": "user", "content": text}],
"temperature": 0,
"top_p": 1,
"max_tokens": 0,
"echo": True,
"logprobs": True,
"top_logprobs": 0,
}
try:
r = requests.post(
f"{VLLM_BASE}/v1/chat/completions", json=payload, timeout=120
)
r.raise_for_status()
items = r.json()["choices"][0]["logprobs"]["content"]
lps = [it["logprob"] for it in items if it.get("logprob") is not None]
except Exception:
alt = {
"model": LLAMA,
"prompt": text,
"temperature": 0,
"top_p": 1,
"max_tokens": 0,
"echo": True,
"logprobs": True,
}
r = requests.post(
f"{VLLM_BASE}/v1/completions", json=alt, timeout=120
)
r.raise_for_status()
lps = [
lp
for lp in r.json()["choices"][0]["logprobs"].get("token_logprobs", [])
if lp is not None
]
if not lps:
lps = [-999.0]
s = sum(lps)
mean_lp = s / max(1, len(lps))
bpb = (-s / math.log(2)) / max(1, len(text.encode("utf-8")))
return mean_lp, bpb
def main():
wait_llama()
cid, texts = fetch_challenge()
with open(f"./challenge_{cid}.json", "w", encoding="utf-8") as f:
json.dump({"id": cid, "texts": texts}, f, ensure_ascii=False, indent=2)
scores = []
for i, text in enumerate(texts, 1):
mean_lp, bpb = score_mean_bpb(text)
scores.append((i - 1, mean_lp, bpb))
print(f"[llama] #{i}: mean_lp={mean_lp:.4f}, bpb={bpb:.5f}")
time.sleep(0.08)
order = sorted(
range(len(texts)),
key=lambda idx: (scores[idx][1], -scores[idx][2]),
reverse=True,
)
llama_idxs = set(order[:TOP_K])
answers = [LLAMA if i in llama_idxs else GEMMA for i in range(len(texts))]
print("\n[*] submit:", answers)
print("[=] server:", submit_answers(cid, answers))
if __name__ == "__main__":
main()
2.4 Results
단일 모델(LLaMA 3.2)만으로도 상위 10개 선별 전략이 안정적으로 동작했고, 서버 채점 결과 정확 판정을 받아 플래그를 획득했다.
2.5 Discussion
정석 접근은 두 후보 모델을 모두 서빙하여 표본별 logprobs를 직접 비교하는 것이다. 그럼에도 LLaMA 3.2 단일 모델의 로그우도만으로도 충분한 신호를 확보할 수 있었다. 솔직히 내가 푼 방식도 정석풀이같긴 하다. (다른사람들 보니까 문장에 규칙이 있어서 좀더 쉽게 풀었다고함)
2.6 Runtime Evidence
다음은 실행 로그에서 핵심 부분만 발췌한 결과다(대표 샘플 + 최종 제출/응답).
id=bfd5641a-ed41-4d0f-8d00-0b51e757cd13, samples=20, schema=for_user
[llama3.2] #1: mean_logprob: -0.6946, bpb: 0.1883, num_tokens: 344
[llama3.2] #2: mean_logprob: -2.1178, bpb: 0.6121, num_tokens: 342
...
[llama3.2] #19: mean_logprob: -0.8869, bpb: 0.2508, num_tokens: 318
[*] submit: ['llama3.2', 'gemma3', 'llama3.2', 'gemma3', 'gemma3', 'llama3.2', 'gemma3', 'gemma3', 'gemma3', 'gemma3', 'llama3.2', 'llama3.2', 'llama3.2', 'llama3.2', 'gemma3', 'llama3.2', 'gemma3', 'gemma3', 'gemma3', 'gemma3']
[=] server: {'result': 'correct whitehat2025{fee40ba0cde992326f520632f07e5a75}'}
3. Leakage Investigation
3.1 Overview
“LumenGrid Labs” 내부에서 신제품 관련 기밀 정보가 외부로 유출되었다. 의심 직원의 네트워크 트래픽 덤프(prob.pcap)를 분석해 실제 유출된 정보(제품명과 출시일)를 복원하는 것이 목표다.
Flag Format: whitehat2025{ProductName_ReleaseDate}
제공된 자료는 다음과 같다.
prob.pcap: 네트워크 패킷 캡처 (약 131 MB, 116,822 packets)passwd: 시스템 계정 정보 파일 (Linux 표준 형식)
3.2 Methodology
-
패킷 구조 분석
Scapy로 전체 패킷 통계를 확인한 결과 대부분의 트래픽은 HTTPS(구글, 마이크로소프트 업데이트 등)로 암호화되어 있었다. 비표준 포트나 특이한 아이피를 찾아보니 내부 IP192.168.110.128과 외부 IP198.51.100.23사이에서 매우 수상한 평문 통신이 지속적으로 관측됐다.from collections import Counter from scapy.all import IP, TCP, rdpcap pkts = rdpcap("prob.pcap") print(f"Total packets: {len(pkts)}") tcp_ports = Counter() for p in pkts: if p.haslayer(TCP): tcp_ports[p[TCP].sport] += 1 tcp_ports[p[TCP].dport] += 1 print("Top TCP ports:", tcp_ports.most_common(10))통계 결과
198.51.100.23:60000 <-> 192.168.110.128:54321연결이 일반 서비스 포트와 달라 주요 분석 대상으로 지정했다. -
평문 문자열 검색
strings로 pcap 파일 내 평문을 추출해 비정상 트래픽의 의미를 파악했다.strings -n 10 prob.pcap > all_strings.txt여기에서 “From now on, let’s hide our messages inside images.” 문장을 발견해 공격자가 이후 스테가노그래피(steganography) 기법을 사용할 것임을 추정했다.
-
TCP 스트림 재구성
해당 문장을 포함하는 패킷을 기준으로 양단 IP/포트를 식별하고, 동일한 TCP 세션의 페이로드를 시간 순으로 병합해 전체 대화를 복원했다.from scapy.all import IP, Raw, TCP, rdpcap pkts = rdpcap("prob.pcap") needle = b"From now on, let's hide our messages inside images." for packet in pkts: if packet.haslayer(Raw) and needle in bytes(packet[Raw].load): src, dst = packet[IP].src, packet[IP].dst sport, dport = packet[TCP].sport, packet[TCP].dport stream = [] for q in pkts: if q.haslayer(IP) and q.haslayer(TCP): cond1 = (q[IP].src, q[IP].dst, q[TCP].sport, q[TCP].dport) == ( src, dst, sport, dport, ) cond2 = (q[IP].src, q[IP].dst, q[TCP].sport, q[TCP].dport) == ( dst, src, dport, sport, ) if cond1 or cond2: stream.append(q) data = b"".join( bytes(q[Raw].load) for q in sorted(stream, key=lambda x: x.time) if q.haslayer(Raw) ) conversation = data.decode("utf-8", errors="ignore") print(conversation[:2000]) break복원된 대화에는 다음과 같은 지시 사항이 포함되어 있었다.
[198.51.100.23] From now on, let's hide our messages inside images. [192.168.110.128] Inside images? How would anyone know to look for them? [198.51.100.23] I'll put a four-digit length at the front. Just read up to that. [192.168.110.128] Got it. How can I notice that an image has a message? [198.51.100.23] I'll invert the red channel so you can notice.이를 통해 메시지가 이미지 내부에 숨겨지며, 4자리 길이 프리픽스와 Red 채널 반전이 시그널이라는 점을 확인했다.
-
이미지 파일 카빙
두 IP 간 스트림을 재조립해 PNG 파일 시그니처가 포함된 블록을 추출했다.from collections import defaultdict from scapy.all import IP, Raw, TCP, rdpcap pkts = rdpcap("prob.pcap") ip1, ip2 = "198.51.100.23", "192.168.110.128" streams = defaultdict(list) for packet in pkts: if packet.haslayer(IP) and packet.haslayer(TCP): if {packet[IP].src, packet[IP].dst} == {ip1, ip2}: sid = f"{packet[IP].src}:{packet[TCP].sport}-{packet[IP].dst}:{packet[TCP].dport}" streams[sid].append(packet) for sid, arr in streams.items(): payload = b"".join( bytes(p[Raw].load) for p in sorted(arr, key=lambda x: x[TCP].seq) if p.haslayer(Raw) ) if b"\x89PNG" in payload: start = payload.find(b"\x89PNG") blob = payload[start:] end_marker = b"IEND\xae\x42\x60\x82" end = blob.find(end_marker) if end != -1: blob = blob[: end + len(end_marker)] name = f"extracted_{sid.replace(':', '_').replace('-', '_')}.png" with open(name, "wb") as f: f.write(blob) print("Saved:", name, len(blob))총 8개의 PNG 파일을 복구했으며 모두 Red 채널이 반전된 패턴을 보였다.
-
스테가노그래피 분석
Red 채널 반전이 있는 이미지를 stylesuxx/steganography 도구로 확인한 결과 평문 메시지가 존재했고, 다음 정보를 얻었다.The product name is “HelioKey.” The release date for this product is “2025-12-25”.
3.3 Results
- ProductName: HelioKey
- ReleaseDate: 2025-12-25
최종 플래그: whitehat2025{HelioKey_2025-12-25}
3.4 Discussion
문제는 네트워크 포렌식과 스테가노그래피 분석을 동시에 요구하는 전형적인 정보 유출 탐지 시나리오였다. 암호화된 HTTPS 트래픽 사이에 남아 있던 평문 문자열(“hide our messages inside images”)이 결정적 단서였고, Red 채널 반전이라는 명시적 신호 덕분에 은닉 데이터의 존재를 빠르게 파악할 수 있었다.
Overall Review
web, crypto 문제가 출제되지 않고 시나리오라는 연속적 문제에 포함하여 출제되었다. 포렌식과 ai문제는 비교적 쉽게 출제된편이나 포렌식엔 조금의 노가다가 포함되어있다. 개인적으로 리버싱과 포너블의 난이도가 다소 높게 출제되었다고 생각한다. 바이브리버싱이 떠오르는 만큼, llm을 잘만 활용할 준비를 해야할것 같다. 일단 본선은 도시락이 맛있다 ㅇㅇ