Mythic 하드닝.
일주일 동안 우리는 Mythic 구축을 멈추고 대신 깨뜨리는 데 집중했다 —— 라이브 서버에 대한 블랙박스, 블랙박스로 부족한 곳은 화이트박스로. 12건의 발견이 나왔다. 전부 수정했고, 회귀 테스트로 고정했으며, 완료라고 부르기 전에 원본 재현으로 다시 검증했다. 이 글은 같은 패스에서 랜딩한 두 개의 M1 로드맵 항목 —— 키 로테이션과 Redis 스토어 —— 도 기록한다.
다운타임 없는 키 로테이션
이번 주까지 Mythic은 모든 결정 토큰을 단일 Ed25519 키로 서명했다;
로테이션은 재시작을 의미했고 살아있는 모든 토큰을 무효화했다.
서버는 이제 정확히 하나의 활성 서명자를 가진 키 링을 유지한다
(ADR-0006).
로테이션은 시간 기반이며 프로세스 내에서 동작한다
(MYTHIC_KEY_MAX_AGE, 기본 720h); 은퇴한 키는
MYTHIC_KEY_RETENTION(기본 24h) 동안 JWKS에 남는다 —
그 키가 서명한 토큰이 유효할 수 있는 시간보다 의도적으로 길어,
전송 중인 토큰이 로테이션 도중에 깨지지 않는다:
그림 1 — 은퇴한 키는 자신이 서명한 토큰보다 오래 산다. 로테이션은 살아있는 토큰을 무효화하지 않는다.
키스토어는 원자적으로 쓰이는 JSON 파일이다; 레거시 32바이트 시드
파일도 로드되며 첫 저장 시 마이그레이션된다. 오리진은 계속 로컬에서
토큰을 검증하고 kid 클레임으로 키를 선택한다 ——
조정 호출이 필요 없다.
단일 노드를 넘어 살아남는 스토어
인메모리 스토어는 mythicd를 단일 노드에 묶어두었다
(ADR-0009).
MYTHIC_STORE=redis는 Redis 백엔드를 선택한다:
일회용 챌린지는 GETDEL(원자적 가져오기-삭제),
프레셔 카운터는 하나의 Lua 스크립트로 가지치기하는 sorted set.
두 백엔드 모두 슬라이딩 윈도우 카운터를 구현해 고정 윈도우의
경계 버스트를 막는다. Store 인터페이스는 이제 에러를
반환한다: 닿지 않는 Redis는 시작을 거부하고, 런타임 장애는
/v1에서 503 store_unavailable로 표출되는
동안 /healthz는 로드밸런서를 향해 열려 있다.
공유 컨트랙트 스위트가 두 백엔드(memory와 miniredis)에 대해 돌아서
시맨틱이 어긋날 수 없게 한다.
문서로 남길 caveat 하나: 공유되는 것은 스토어지 서명 키가 아니다. 노드 A의 토큰은 노드 B의 JWKS로 검증되지 않는다. 멀티노드 토큰 검증에는 공유 키스토어(KMS/HSM)가 필요하다 —— M5이며, 이번 변경으로 해결되지 않았다.
12건의 발견, 그룹별 정리
각 행: 공격이 보여준 것, 그리고 바뀐 것. 모든 수정에는 회귀 테스트가 있고, 모든 수정은 원본 재현으로 다시 검증했다.
| 영역 | 원인 | 수정 |
|---|---|---|
| 전송 및 제한 | ||
| 레이트리밋 예산 | 예산이 검증 후에 소진됐다 —— 잘못된 JSON과 알 수 없는 사이트 키 플러드가 스로틀링 없이 통과했다 (600 요청, 225 req/s, 429 제로). | 예산은 이제 검증 전에 카운트된다. |
| IPv6 프레셔 키 | 리미터가 IPv6를 /128로 키잉했다 —— 하나의 /64가 2⁶⁴개의 신원을 장악했다 (단일 블록에서 5,000 요청, 히트 제로). | 카운터는 IPv6를 /64로 집계한다 (ADR-0007). |
| 연결 타임아웃 | body/read/idle 타임아웃이 없었다 —— 느린 클라이언트가 연결을 무기한 점유했다. | ReadTimeout, WriteTimeout, IdleTimeout을 설정했다. |
| 챌린지 경제성 | ||
| 솔브 시간 플로어 | 플로어는 50k H/s 워커를 모델링했다; 실측 crypto.subtle 워커는 약 400k H/s —— 정직한 클라이언트가 비현실적이라고 판정됐다. |
플로어는 이제 정직한 솔브 시간의 낮은 분위수다 (2^d / (2M × 32)): 최악의 경우 오탐 ≤ ~6%, GPU급 답안은 약 96% 걸린다 (ADR-0008). |
| 지연된 검증 | 클라이언트는 검증만 벽시계 플로어 뒤로 미루면 됐다 —— 플로어는 인내를 잡지 못한다. | 위협 모델에 행동 레이어(M2)의 일로 문서화했다. 플로어의 일이 아니다. |
| 토큰과 설정 | ||
| 토큰 TTL | “토큰이 챌린지보다 오래 살지 않는다”는 문서화된 불변식은 강제되지 않았다 — 5초짜리 챌린지에서 1시간짜리 토큰이 발행됐다. | TokenTTL은 이제 ChallengeTTL로 클램프된다. |
| 점수 클램프 | 적대적 튜닝(MYTHIC_FAST_SOLVE_PENALTY=500)이 서명된 토큰에 500의 리스크 점수를 누출했다. |
점수는 엔진 경계에서 0–100으로 클램프되고 100을 넘는 페널티는 시작을 거부한다. |
| 설정 검증 | 음수 MYTHIC_BASE_DIFFICULTY는 아무 nonce나 통과하는 챌린지를 발행했다. |
설정은 시작 시 검증되고 범위를 벗어난 값은 서비스를 거부한다. |
| Base64 정규형 | Go의 base64 디코더는 trailing 문자의 미사용 하위 비트를 조용히 버리기 때문에, 서로 다른 토큰 문자열이 같은 바이트로 디코딩되어 검증을 통과했다 — 문자열 수준의 토큰 동일성이 깨진 것이다. 변조 저항 뮤테이션 스위트의 첫 실행에서 잡았다. | VerifyToken은 이제 canonical base64를 강제한다: 디코딩된 바이트를 다시 인코딩한 결과가 입력과 정확히 일치하지 않으면 토큰을 거부한다. |
| 상태 처리 | ||
| 키스토어 동시성 | 하나의 키스토어를 공유하는 두 프로세스는 같은 임시 파일에서 충돌하거나 서명 키를 조용히 스플릿브레인했다. | 임시 파일명은 프로세스별 고유; 저장은 로드 후 변경된 파일에 덮어쓰기를 거부한다. |
| JSON trailing 바이트 | JSON 값 뒤의 trailing 바이트는 조용히 무시됐다. | trailing 바이트는 이제 거부된다. |
| 카운터 포화 | 슬라이딩 윈도우 카운터는 히트마다 (레이트리밋된 것 포함) 하나의 엔트리를 저장했다 — 하나의 신원에서 백만 히트가 약 16.4 MB 힙과 595초 CPU를 썼다. | 카운터는 IPLimit+1에서 포화된다: 같은 플러드도 +0.00 MB, 714 ms밖에 안 든다. |
점수 클램프 수정은 보여줄 가치가 있다. 신뢰 경계에 있기 때문이다 —— 토큰 안에 서명되어 들어가는 값:
// The score is a 0-100 contract (token claim `risk`,
// dashboards, tuning docs). Hostile tuning must clamp
// here, not leak 500 into tokens (red-team H3).
if score > 100 {
score = 100
}
if score < 0 {
score = 0
}
검증 —— 그리고 아직 열린 문제
모든 발견에는 재현이 있고, 모든 수정에는 회귀 테스트가 있다
(go test ./... -race, plus SDK 타입체크와 테스트).
그리고 모든 수정은 원본 재현으로 다시 검증했다. 아직 열린 것은
우리 머릿속이 아니라 위협 모델에 문서화되어 있다: 검증을 지연시키는
인내심 많은 솔버 (M2, 행동 신호), 새 신원을 쓰는 분산 공격
(이후 M1 네트워크 지문으로 부분 해결 ——
003 참조), 노드별 키스토어
(M5, 공유 KMS/HSM).
— XEY LABS