
Rejetto HFS라는 파일 서버가 관리자 세션 쿠키에 서명할 때 쓰는 키는 Math.random()으로 만든 30자짜리 문자열이었습니다. 현지시각으로 지난주 수요일 이 사실이 상세 분석과 함께 공개됐고(CVE-2026-61500), 다음 날 저녁에 실제 공격이 들어왔습니다.
HFS는 폴더 하나를 통째로 웹에 띄우는 용도로 오래 쓰여 온 작은 파일 서버입니다. 3.x 버전은 Node.js로 다시 쓰였고요. 이번에 문제가 된 것은 3.0.0부터 3.2.0까지고, 3.2.1에서 고쳐졌어요. 심각도는 CVSS 3.1 기준 9.8, 4.0 기준 9.3으로 매겨졌습니다. 로그인하지 않은 상태에서 관리자 세션을 위조하고 서버에서 임의 코드를 실행하는 데까지 갈 수 있으니 숫자가 저렇게 나온 것이죠. 찾아낸 사람은 침투 테스트 회사 Horizon3.ai의 수석 공격 엔지니어 잭 핸리(Zach Hanley)이고, 그 과정에서 앤트로픽의 모델 Mythos를 썼습니다. 제한된 파트너에게만 열려 있는 프로젝트 글래스윙(Project Glasswing)을 통해서였습니다.

HFS의 관리 화면. 2011년에 공개된 2.3 베타 시절 화면입니다. 2.x는 네이티브 앱이었고요. 출처: Wikimedia Commons, rejetto
사건의 모양부터 정리하는 편이 좋겠습니다. 서버가 기동할 때 randomId(30)이라는 함수가 한 번 돕니다. 안에서 Math.random().toString(36)을 세 번 불러 이어 붙인 뒤 앞에서 30자를 잘라내요. 이 문자열이 그 서버가 살아 있는 동안 Koa 세션 쿠키를 서명하는 키가 됩니다. 여기까지만 보면 정적 분석 도구가 매일 올려 보내는 그 소견입니다. CWE-338, 암호학적으로 안전하지 않은 난수 생성기 사용. 대개는 "심각도 낮음, 실제 악용 경로 없음"으로 닫히는 항목이지요.
문제는 같은 서버의 다른 경로에 있었습니다. 로그인 절차의 첫 단계인 loginSrp1 요청은 인증을 요구하지 않는데요. 이 요청이 들어올 때마다 서버는 Math.random()을 한 번 더 불러 만든 식별자를 세션 쿠키에 담아 호출한 쪽에 그대로 돌려줍니다. 그 자체로는 아무 문제가 없는 동작입니다. 로그인하려는 클라이언트에게 핸드셰이크 식별자를 주는 것뿐이니까요. 다만 이 난수와 아까 그 서명 키는 같은 난수 생성기에서 나옵니다.
▸ 155비트처럼 보이지만 상태는 128비트입니다
여기서부터는 직접 확인해 보는 편이 빠릅니다. (실은 이것 때문에 글을 쓰기 시작했습니다.) Node v22에서 Math.random()을 부르고 결과에 2의 52제곱을 곱해 보면 언제나 정수가 나옵니다. 십만 번을 돌려도 예외가 한 번도 없어요. 한 번의 호출이 바깥으로 내주는 것이 정확히 52비트라는 뜻입니다. V8이 쓰는 생성기는 xorshift128+이고 내부 상태는 128비트인데, 호출 한 번마다 그중 52비트가 그대로 보이는 셈이죠. 나머지 버려지는 부분은 전수 탐색이 가능한 크기입니다.
그러면 30자짜리 키를 다시 보게 됩니다. base36 문자 30개는 겉보기에 30 × log2(36), 그러니까 약 155.1비트처럼 읽힙니다. 넉넉해 보이죠. 그런데 그 155비트를 만들어 낸 생성기의 내부 상태는 128비트고, 그 128비트는 바깥에서 관측할 수 있습니다. 공개된 기술 분석에 따르면 인증 없는 로그인 요청 여섯 번으로 연속된 출력 다섯 개를 모으면 상태가 복원되고, 상태가 복원되면 과거와 미래의 모든 출력이 예측 가능해집니다. 보도에 따르면 Mythos는 여기에 마이크로소프트의 Z3 SMT 솔버를 쓰면 시드를 되돌릴 수 있다는 점까지 짚었습니다.
남은 단계는 기계적입니다. 복원한 상태를 서버 기동 시점까지 되감아 서명 키 후보를 뽑고, 서버가 실제로 보낸 쿠키의 HMAC와 맞춰 보면 어느 후보가 진짜인지 가려집니다. 그다음에는 { username: "admin" }이라고 적은 세션 객체를 같은 키로 서명하면 됩니다. 서버 입장에서 이 쿠키는 자기가 발급한 것과 구별되지 않아요. 관리자로 들어간 뒤에는 set_config를 호출해 HFS의 server_code 설정에 자바스크립트를 넣을 수 있고, 그 코드는 서버 프로세스 권한으로 실행됩니다. 3.2.1의 수정은 간단합니다. randomBytes(32)와 randomUUID()로 바꿨습니다. 결국 155비트처럼 보이던 키의 실제 안전성은 155비트도 128비트도 아니었던 셈입니다. 관측 가능해진 순간 0이 된 것이죠.
▸ 각각은 취약점이 아니었습니다
이 사건을 "AI가 취약점을 찾았다"로 요약하면 정작 새로운 부분이 지워집니다. 앞에서 본 두 가지 소견을 따로 떼어 놓고 보면 어느 쪽도 취약점이 아니거든요. 서명 키를 Math.random()으로 만든다는 것은 심각도 낮음으로 닫히는 린트 경고이고, 로그인 엔드포인트가 세션 식별자를 돌려준다는 것은 설계대로 동작하는 정상 기능입니다. 9.8이라는 숫자는 둘 중 어느 하나에서 나오지 않았습니다. 둘을 이어 붙인 자리에서 나왔어요.
서로 다른 코드 경로에 흩어져 있는 소견들을 체인으로 보는 일은 정적 분석기가 구조적으로 못 하는 일입니다. 규칙 하나에 패턴 하나가 대응하도록 만들어진 도구니까요. 사람은 할 수 있지만 보통 하지 않습니다. 시간이 없기 때문이고, 더 정확히는 그 두 항목이 서로 다른 티켓에, 서로 다른 담당자에게, 서로 다른 우선순위로 올라가 있기 때문입니다. 보도된 바로는 Mythos가 한 일이 정확히 이 지점이었습니다. 안전하지 않은 난수 생성기를 지적하는 데서 멈추지 않고, 같은 애플리케이션의 다른 경로가 그 생성기의 원본 출력을 밖으로 흘리고 있다는 사실을 함께 짚었으며, 그 출력으로 시드를 되돌릴 수 있다는 데까지 판단했다는 것이죠.
다만 모델이 없었으면 이 체인을 찾지 못했을 것이라고 단정할 수는 없습니다. 두 소견 모두 사람이 찾을 수 있는 종류이고, 실제로 어딘가에서 이미 찾아 두었을 수도 있어요. 더 정확한 표현은, 이 조합을 끝까지 따라가 볼 가치가 있는지 판단하는 비용이 내려갔다는 쪽입니다. 보안 백로그에 "낮음"으로 쌓여 있는 항목 수백 개 중 어느 둘이 짝이 되는지 확인하는 작업은, 지금까지는 누군가 하루를 통째로 써야 하는 일이었습니다.
행위의 주체를 어디에 두느냐도 짚어 둘 만합니다. 헤드라인은 대체로 "앤트로픽의 모델이 찾았다"로 적혔는데요. 정작 HFS 릴리스 노트에 올라간 크레딧 문구는 "Zach Hanley of Horizon3.ai, in collaboration with Claude and Anthropic Research"입니다. 문제를 고르고, 방향을 잡고, 익스플로잇이 실제로 동작하는지 확인하고, CVE 번호를 받기 위해 신고한 쪽은 사람입니다.
▸ 286건 중 두 번째, 그리고 하루
숫자 하나를 더 보겠습니다. 금요일 기준으로 VulnCheck의 보안 연구원 패트릭 개리티(Patrick Garrity)가 운영하는 트래커에 올라 있는 Mythos·프로젝트 글래스윙 발 CVE는 286건이고, 그중 실제 공격에 쓰인 것이 확인된 사례는 이번이 두 번째입니다. 앞선 한 건은 Ghost CMS의 CVE-2026-26980이었어요. 이 숫자는 양쪽으로 읽힙니다. 프로그램이 찾아내는 것들이 대체로 아무도 노리지 않는 대상이라는 뜻일 수도 있고, 공개된 취약점이 실제로 악용되는 기저율 자체가 원래 낮다는 뜻일 수도 있어요. 어느 쪽인지 출처는 말하지 않습니다.
대신 간격은 분명합니다. 공개가 수요일이었고, 개리티가 자사 카나리아에서 악용 시도를 탐지한 것이 바로 다음 날 목요일 저녁입니다. 중국 소재의 단일 IP가 미국과 일본의 실제 취약 호스트를 노렸고, 금요일에는 같은 서브넷의 미국 IP 두 개에서 네 건이 더 들어왔습니다. 프록시를 경유한 것으로 보인다고 했어요. 다만 공격자가 공개된 분석글을 보고 익스플로잇을 재구성한 것인지, 독립적으로 만들어 낸 것인지는 어느 출처도 밝히지 않았습니다.
그래서 이 글의 결론이 "Math.random()을 쓰지 말자"는 한 줄은 아닙니다. 그건 이미 모두가 아는 이야기이고, 알면서도 닫히지 않는 티켓으로 남아 있는 것이 문제였으니까요. 내일 당장 확인할 수 있는 것들은 따로 있습니다. 식별자나 토큰, 키를 만드는 자리에 crypto.randomBytes와 crypto.randomUUID가 들어가 있는지. 세션 서명 키를 기동할 때마다 새로 만들고 있지는 않은지, 환경변수나 시크릿 저장소에서 주입받고 있는지. 인증을 요구하지 않는 엔드포인트가 난수 생성기의 원본 출력을 그대로 응답에 실어 보내고 있지는 않은지. 그리고 하나 더, "심각도 낮음, 악용 경로 없음"으로 닫아 둔 백로그를 항목 하나하나의 등급이 아니라 짝이 되는 항목이 있는지를 기준으로 한 번 다시 읽어 보는 일입니다.
마지막 항목이 지금까지는 사람의 시간이 모자라서 못 하던 일이었습니다. 그 비용이 내려갔다면 공격하는 쪽에서도 같이 내려갔겠죠. 수요일에 공개된 것이 목요일 저녁에 들어온 이번 간격이 앞으로 더 줄어들지는 지켜볼 일이지만, 패치가 이미 나와 있었는데도 그 안에서 움직일 시간이 하루였다는 사실은 남습니다. 고칠 목록을 우선순위대로 정렬하는 기준을, 항목의 등급에서 항목들의 조합으로 옮겨 놓을 때가 된 것 같습니다.
커버 이미지: 여러 가지 주사위 — Wikimedia Commons, Dietmar Rabich
전체 사이트에서 댓글·관련 글을 함께 보시려면
이야기 공장에서 보기 →