1 Introduction
본문서는 2025 WHITEHAT 예선에 참가한팀헤일메리의문항별WriteUp이며, AI sommelier, Leakage
Investigation만을 다룬다(나머진 귀찮음)
2 AI sommelier
2.1 Overview
서버는 llama3.2가생성한텍스트 10개와gemma3가생성한텍스트 10개(총20개)를 무작위로제공한다.
참가자는 각텍스트의생성모델을판별하여 정답을제출하면 플래그를 획득한다.
핵심가정은다음과같다. 언어모델은 자기가 생성했을 법한 텍스트에 대해 상대적으로 더높은 로그우도
(log-likelihood)를 부여한다. 따라서후보모델을실제로구동한뒤입력텍스트에 대한logprobs를 관측하면
모델기원분류(model attribution)가가능하다.
2.2 Methodology
입력x = (t1, . . . , tn)에 대해모델M 의로그우도합은
log pM (x) =
n
i=1
로정의된다. 길이편향을줄이기위해평균로그우도
mean
_logprob(x) = 1
n
n
i=1
를 사용하고, 근소차이의안정화를 위해바이트 단위교차엔트로피
−log pM (x)
bpb(x) =
log pM (ti |t<i)
log pM (ti |t<i)
(ln 2) ·|x|bytes
를 보조지표로사용하였다(값이작을수록우수).
실환경제약(nvidia TESLA T10 16GB, gemma3의bf16 요구)으로gemma3의로컬 서빙이곤란하였으므로,
llama3.2만 구동하여 20개텍스트 모두에 대한mean_logprob을산출하고, 그값이큰 상위10개를 llama3.2
산출물로, 나머지10개를 gemma3 산출물로간주하여 제출하였다.
1
2.3 Implementation
Serving. llama3.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. 문제서버에서텍스트를 수신한뒤,각텍스트를 completions 엔드포인트로echo=true, max_tokens=0,
logprobs=true 인자와함께평가하여 토큰 로그확률을회수하고mean_logprob/bpb를 계산하였다.
import os, time, math, json, requests
CHALLENGE_BASE = os.getenv("CHALLENGE_BASE","http://43.203.171.66: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)
if LLAMA in [m.get("id") or m.get("name") for m in r.json().get("data", [])]: 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]
2
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") as f: json.dump({"id":cid,"texts":texts}, f,
ensure_ascii=False, indent=2)
scores=[]
for i,t in enumerate(texts,1):
mean_lp,bpb=score_mean_bpb(t); 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 i:(scores[i][1], -scores[i][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
단일모델(llama3.2)만으로도상위10개선별전략이안정적으로동작하였고, 서버가정답으로판정하여
플래그를 획득하였다.
2.5 Discussion
정석접근은두후보모델을모두서빙하여 표본별logprobs를 직접비교하는 것이다. 그럼에도llama3.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: 344
[llama3.2] #20: mean_logprob: -2.0789, bpb: 0.5638, num_tokens: 325
[’llama3.2’,’gemma3’,’llama3.2’,’llama3.2’,’llama3.2’,
’gemma3’,’llama3.2’,’gemma3’,’gemma3’,’gemma3’,
’llama3.2’,’gemma3’,’gemma3’,’gemma3’,’gemma3’,
’llama3.2’,’llama3.2’,’llama3.2’,’llama3.2’,’gemma3’]
{’result’: ’correct whitehat2025{fee40ba0cde992326f520632f07e5a75}’}
3
3 Leakage Investigation
3.1 Overview
“LumenGrid Labs” 내부에서신제품 관련기밀정보가외부로유출되었다. 의심되는 직원의네트워크 트래픽
덤프(prob.pcap)를 분석하여, 실제유출된정보(제품명 및출시일)를 복원하는 것이목표이다.
Flag Format: whitehat2025{ProductName_ReleaseDate}
3.2 Methodology
제공된자료는 다음과같다:
• prob.pcap: 네트워크 패킷 캡처 (약 131MB, 116,822 packets)
• passwd: 시스템 계정정보파일(Linux 표준형식)
분석은다음단계로진행하였다.
1) 패킷 구조 분석. Scapy를 통해전체 패킷 통계를 확인한결과, 대부분의트래픽은HTTPS(구글, 마
이크로소프트 업데이트 등)로암호화되어 있었다. 그러나 비정상적인내부 IP 192.168.110.128과외부 IP
198.51.100.23 간의평문 통신이지속적으로감지되었다.
from scapy.all import *
from collections import Counter
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 연결이일반적인서비스포트와맞지않아
주요분석대상으로지정하였다.
2) 평문 문자열 검색. pcap 파일에서평문 문자열을검색하여 비정상 트래픽의의미를 파악하였다.
strings -n 10 prob.pcap > all_strings.txt
검색 결과, 다음문장이발견되었다.
From now on, let’s hide our messages inside images.
이문장을통해공격자가이후스테가노그래피(steganography) 기법을사용할것임을유추할수있었다.
4
3) TCP 스트림 재구성. 해당 문장을포함하는 패킷을기준으로양단 IP/포트를 식별하고, 동일한TCP
세션내의모든페이로드를 시간순서대로병합하여 전체 대화를 복원하였다.
from scapy.all import *
pkts = rdpcap(’prob.pcap’)
needle = b"From now on, let’s hide our messages inside images."
for i, p in enumerate(pkts):
if p.haslayer(Raw) and needle in bytes(p[Raw].load):
src, dst = p[IP].src, p[IP].dst
sport, dport = p[TCP].sport, p[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))
print(data.decode(’utf-8’, errors=’ignore’)[: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.
이를 통해, 메시지는 이미지파일내부에 숨겨지며, Red 채널반전과4자리 길이 프리픽스가삽입된것으로
확인되었다.
4) 이미지 파일 카빙. 두IP 간스트림 중PNG 파일서명을포함하는 데이터 블록을추출하였다.
from scapy.all import *
from collections import defaultdict
pkts = rdpcap(’prob.pcap’)
ip1, ip2 = "198.51.100.23", "192.168.110.128"
streams = defaultdict(list)
for i, p in enumerate(pkts):
if p.haslayer(IP) and p.haslayer(TCP):
if {p[IP].src, p[IP].dst} == {ip1, ip2}:
sid = f"{p[IP].src}:{p[TCP].sport}-{p[IP].dst}:{p[TCP].dport}"
5
streams[sid].append(p)
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:]
endmk = b’IEND\xae\x42\x60\x82’
end = blob.find(endmk)
if end != -1:
blob = blob[:end+len(endmk)]
name = f"extracted_{sid.replace(’:’,’_’).replace(’-’,’_’)}.png"
with open(name, ’wb’) as f:
f.write(blob)
print("Saved:", name, len(blob))
총8개의PNG 이미지파일이추출되었으며, 모두동일한Red 채널반전패턴을보였다.
5) 스테가노그래피분석. Red 채널반전이존재하는 이미지를 분석한결과평문 메시지가존재함을확인
하였다. (https://stylesuxx.github.io/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 채널반전이라는 명시적시그널을통해은닉 데이터의존재를 확인할수있었다
