XEY LABS — MYTHIC / 002 EN/ ID/ 中文/ 日本語/ 한국어 ← BUILD LOG 프로젝트 REPO ↗ GITHUB ↗

Mythic 하드닝.

일주일 동안 우리는 Mythic 구축을 멈추고 대신 깨뜨리는 데 집중했다 —— 라이브 서버에 대한 블랙박스, 블랙박스로 부족한 곳은 화이트박스로. 12건의 발견이 나왔다. 전부 수정했고, 회귀 테스트로 고정했으며, 완료라고 부르기 전에 원본 재현으로 다시 검증했다. 이 글은 같은 패스에서 랜딩한 두 개의 M1 로드맵 항목 —— 키 로테이션과 Redis 스토어 —— 도 기록한다.

다운타임 없는 키 로테이션

이번 주까지 Mythic은 모든 결정 토큰을 단일 Ed25519 키로 서명했다; 로테이션은 재시작을 의미했고 살아있는 모든 토큰을 무효화했다. 서버는 이제 정확히 하나의 활성 서명자를 가진 키 링을 유지한다 (ADR-0006). 로테이션은 시간 기반이며 프로세스 내에서 동작한다 (MYTHIC_KEY_MAX_AGE, 기본 720h); 은퇴한 키는 MYTHIC_KEY_RETENTION(기본 24h) 동안 JWKS에 남는다 — 그 키가 서명한 토큰이 유효할 수 있는 시간보다 의도적으로 길어, 전송 중인 토큰이 로테이션 도중에 깨지지 않는다:

키스토어는 원자적으로 쓰이는 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