
4줄짜리 보안 수정 사항이 100개 파일의 Pull Request로 변하고, 하마터면 그대로 병합할 뻔한 이야기
요약
AI 보조 개발 시스템을 활용해 보안 라이브러리를 관리하던 중, JavaScript의 프로토타입 오염 취약점으로 인해 보안 가드가 무력화될 뻔한 사례를 다룹니다. AI 모델 리뷰 패널과 CI 시스템이 심각한 보안 버그를 감지하지 못하고 통과시킨 과정과 그 위험성을 설명합니다.
핵심 포인트
- JavaScript 프로토타입 오염이 보안 체크 로직을 어떻게 무력화하는지 설명
- AI 모델 리뷰 패널(GPT, Claude 등)이 보안 취약점을 놓칠 수 있는 위험성 경고
- 보안 라이브러리 개발 시 AI 보조 도구에 대한 과도한 의존 주의 필요
- 객체 속성 검증 시 프로토타입 오염을 방지하는 안전한 코딩 방식의 중요성
저는 원격 MCP 서버를 위한 오픈 소스 OAuth 2.1 레이어인 mcp-sso를 관리하고 있습니다. 쉽게 말해, 사용자가 가공되지 않은 API 키를 직접 넘겨주지 않고도 AI 도구가 서버에 안전하게 연결할 수 있도록 해주는 로그인 및 권한 부여 레이어입니다. 이 라이브러리는 동적 클라이언트 등록 (dynamic client registration), CIMD, PKCE 및 오디언스 제한 토큰 (audience-bound tokens, 하나의 서버를 위해 발행된 토큰이 다른 서버에서는 거부됨)과 같은 최신 ID 표준을 포함하여, 대부분의 사람들이 건너뛰는 사양에 맞는 까다로운 작업들을 수행합니다. 또한 Microsoft Entra ID, Google, Cloudflare Access를 포함한 실제 엔터프라이즈 ID 제공업체를 대상으로 엔드 투 엔드 (end-to-end) 실시간 검증을 거칩니다. 저는 이 위에 MCP 게이트웨이 멀티플렉서 (MCP gateway multiplexer)도 구축하고 있습니다. 즉, 모든 MCP 서버에 대해 동일한 인증을 사용하는, 여러 MCP 서버를 위한 하나의 보안된 정문 역할을 합니다.
이것은 **보안 라이브러리 (security library)**이므로, 저는 모든 변경 사항을 보안에 결정적인 것으로 취급하며 AI 보조 개발 (AI-assisted development)에 크게 의존합니다. 여기에는 각 변경 사항을 두고 논쟁하는 여러 연구소의 다양한 AI 모델들 (GPT, Grok, GLM, Claude)로 구성된 리뷰 패널이 포함됩니다.
이번 주에 이 전체 시스템 (통과된 테스트 스위트, 멀티 모델 리뷰 패널, CI)이 모두가 심각하다고 간주했던 버그를 통과했습니다. 실제로 악용 가능한 상황이 사용자에게 도달한 적은 없었습니다. 해당 버그가 포함된 기능이 활성화되거나 출시되기도 전에 포착되었기 때문입니다. 하지만 버그는 메인 브랜치 (main branch)에 있었고, 심각한 보안 취약점처럼 보였으며, 모든 검사를 통과해 버렸습니다. 그리고 그 버그를 수정하려던 과정에서, 동일한 시스템이 실제로 발생할 수 없는 공격을 방어하기 위해 보안 라이브러리 전체를 거의 배포할 뻔했습니다.
제가 실수했던 부분들을 포함하여 전체 이야기를 들려드리겠습니다.
먼저, 실제 버그에 대해 (쉽게 설명하자면)
라이브러리의 일부는 인터넷에서 문서를 가져옵니다 (fetch). 코드가 외부 웹 요청을 보낼 때마다 발생하는 위험은, 공격자가 코드를 속여서 사용자의 네트워크 내부의 개인 주소와 같이 호출해서는 안 되는 곳을 호출하게 만드는 것입니다. 따라서 이 페처 (fetcher)에는 127.0.0.1 (사용자의 로컬 머신)과 같은 내부 주소로의 요청을 차단하는 가드 (guard)가 있습니다.
그 가드 (guard)에는 한 가지 옵션이 있었습니다: "allow loopback(루프백 허용)", 즉 "이 요청이 로컬 머신에 도달하도록 허용함"을 의미하며, 이는 오직 로컬 테스트를 위해서만 의도된 것이었습니다. 코드는 다음과 같이 이를 확인했습니다:
if (opts.allowLoopback === true) { /* 127.0.0.1 허용 */ }
여기에 함정이 있는데, 이는 JavaScript의 기묘한 특성 (quirk) 때문입니다. JavaScript의 모든 객체는 공유된 부모 객체로부터 조용히 속성 (properties)을 상속받습니다. 만약 공격자가 그 공유된 부모에 값을 심을 수 있다면 (이를 프로토타입 오염 (prototype pollution)이라고 합니다), 평범하고 비어 있는 객체가 갑자기 그 값을 자신의 값인 것처럼 보고하게 됩니다. 따라서 이 체크는 아무도 설정하지 않았음에도 allowLoopback을 "true"로 읽을 수 있으며, 비밀리에 127.0.0.1을 가리키는 공개 웹 주소가 가드를 그대로 통과해 버릴 수 있었습니다. 저는 이를 엔드 투 엔드 (end to end)로 확인했습니다.
수정 방법은 지루합니다. 이 특정 객체가 실제로 해당 옵션을 설정했을 때만 읽어야 하며, 상속받은 것이 아니어야 합니다:
Object.hasOwn(opts, "allowLoopback") && opts.allowLoopback === true
세 개의 파일에 걸친 네 줄의 코드. 그것이 실제 버그의 전부였습니다.
버그를 발견한 방법
한 차례의 작업 배치가 막 병합되었고, 다음 작업을 시작하기 전에 제가 배포한 것에 대해 적대적인 제2의 의견 (adversarial second opinion)을 듣고 싶었습니다. 즉, 제 자신의 코드에 대한 심층적인 감사 (audit)였습니다. SSRF 가드를 점검하고, 로컬 TLS 서버를 구축하고, 타임아웃 (timeout)이 실제로 소켓 (socket)을 해제하는지 증명하는 식입니다. 고정된 테스트 스위트 (test suite)가 구조적으로 도달할 수 없는 모든 것을 찾아내는 작업입니다.
먼저 Fable에게 이를 지시했습니다. 하지만 거절당했습니다. 세션이 일시 중단되었고, 세이프가드 (safeguards)가 해당 요청을 차단했다고 알려주었습니다. 세이프가드는 "현재 의도적으로 광범위하게 설정되어 있어 안전하고 일상적인 코딩, 사이버 보안 또는 생물학 관련 작업을 차단할 수 있음"이라고 명시했습니다. 저는 모델에게 제 보안 라이브러리를 감사해 달라고 요청한 것이었는데, 세이프가드는 이를 공격과 구분하지 못했습니다. 정당한 보안 작업을 수행할 때, 이는 저에게 반복되는 제약 사항이 되었습니다.
그래서 저는 동일한 감사(audit) 내용을 GPT-5.6 Sol의 가장 비싼 설정인 Ultra 모드에 맡겼습니다. 그리고 그것은 작동했습니다, 어쩌면 너무 과하게 말이죠. 제가 의도적으로 Ultra를 선택한 이유는 이 모델이 이러한 적대적 검토(adversarial review)에서 얼마나 유능한지 확인하고 싶었기 때문입니다. 결과는 성공적이었습니다, 어쩌면 너무 잘 해냈을 정도였습니다. Sol은 테스트 스위트(test suite)가 도달할 수 없는 경로를 탐색하여 실제 버그를 찾아냈습니다. 공정하게 평가하자면, 그 감사는 훌륭했습니다. 문제는 그 이후에 발생했습니다. 동일한 모델이 하나의 유효한 발견 사항을 경계가 없는 저장소 전체의 수정 사항으로 변환하도록 내버려 두었을 때 말입니다.
그러더니 제 통제를 벗어났습니다
여기서부터 상황이 잘못되었고, 바로 이 부분이 흥미로운 지점입니다.
"자신의 값을 의도한 곳에서 상속된 값을 읽는 것"은 단일 버그가 아닙니다. 이는 객체가 읽히는 모든 곳에 존재할 수 있는 패턴입니다. 따라서 저와 AI의 자연스러운 본능은 이를 모든 곳에서 수정하는 것이었습니다. 그리고 "모든 곳"에는 경계가 없습니다.
몇 차례의 과정을 거치자, 그 "작은 수정"은 자체적인 새로운 라이브러리를 구축하고 전체 저장소(repo)로 퍼져 나갔습니다. 해당 풀 리퀘스트(Pull Request)는 104개의 파일과 7,000개에 달하는 변경된 라인에 도달했습니다(이것은 공개되어 있으며, 종료되었으나 병합되지 않은 상태입니다: PR #88). 매 검토 단계마다 더 많은 문제점이 발견되었고, 그 새로운 문제들은 방금 풀 리퀘스트가 추가한 코드 내에 있었습니다. 라운드를 거듭할수록 변경 사항은 커졌고, 수정해야 할 목록은 길어졌습니다.
그리고 반전이 일어났습니다. 프로토타입 오염(prototype pollution)을 막기 위해 구축한 바로 그 라이브러리가, 자신이 방지하려고 했던 것과 정확히 일치하는 안전하지 않은 상속 방식으로 자신의 내부 데이터를 읽고 있었던 것입니다. 구체적으로는, 이 라이브러리는 "value" in descriptor를 통해 디스크립터(descriptor)를 확인했는데, in 연산자 자체가 프로토타입 체인(prototype-chain)을 읽는 방식이었습니다. 따라서 오염된 프로토타입은 오염을 탐지하는 데 사용하는 바로 그 디스크립터를 위조할 수 있었습니다. 치료제가 질병을 품고 있었던 셈입니다.
그 순간 마침내 패턴이 이해되었고, 이것이 제가 사람들이 꼭 기억해주길 바라는 단 한 문장입니다: AI에게 코드의 모든 줄에 영향을 미치는 규칙을 부여하면, AI는 스스로의 작업물을 계속해서 생성해내는 기계로 변합니다. "모든 값은 안전하게 읽혀야 한다"라는 규칙은 어디에나 적용될 수 있으므로, 멈춰야 할 자연스러운 지점이 없습니다. 리뷰는 결코 끝나지 않습니다. AI는 자신이 계속해서 써 내려가는 코드에서 끊임없이 문제를 찾아낼 뿐입니다. AI가 철저했던 것이 아닙니다. 끝이 없는 업무를 부여받았던 것입니다.
여기서 한 가지는 제 잘못이며, 이는 '나쁜 한 시간'과 '나쁜 하루'의 차이를 만듭니다. 규칙이 작업을 무한하게 만들었다면, 그 _설정(setup)_은 작업이 감시 없이 실행되도록 방치했습니다. 제가 우선적으로 선택했던 모델인 Fable은 보안 관련 작업을 거부했습니다 (여러 방식으로 프레이밍을 시도했으나 모두 거절당했습니다). 그래서 저는 Ultra의 가장 비싼 설정이자 이 작업에는 과한(overkill) GPT-5.6 Sol을 사용했습니다. 그리고 아무런 안전장치(tripwire)도, 지켜보는 사람도 없이 제 개인 라이브러리에 이를 방치한 채 실행했습니다. 당시 저는 다른 작업을 하고 있었고, 보통은 몇 분 후에 돌아오곤 하지만 이번에는 그대로 두었습니다. 그리고 Sol은 끊임없이 목표 지향적입니다. 작업이 완료될 때까지 계속 진행하는데, 이는 끝이 없는 작업에 적용되어서는 안 될 최악의 특성입니다. 추진력이 덜한 모델이었다면 스스로 멈췄을지도 모르지만, 이 모델은 그렇지 않았습니다. 결여된 결정들이 불을 지폈고, 감시 없이 실행되는 과도하고 끈질긴 모델이 제가 알아차리기 전까지 몇 시간 동안 불길이 타오르게 두었습니다. 그 이후로 저는 방치된 실행에 제한을 두고 정지 조건(stop condition)을 필수적으로 요구하고 있습니다.
6시간 이상 경과 후, 무언가 앞뒤가 맞지 않았다
이런 상태로 6시간 이상이 흐른 뒤, 저는 의구심이 생겼습니다. 변경 사항은 계속해서 쏟아져 나왔고, 그중 어느 것도 "완료"를 향해 수렴하고 있다는 느낌이 들지 않았습니다. 그래서 저는 작업을 멈추고, 맨 처음 물었어야 했던 질문을 던졌습니다: 이 버그가 실제로 악용될 수 있는가?
그 원래의 "심각한 (critical)" 버그에 대한 사실은 이렇습니다. 이를 실행하기 위해서는 공격자가 먼저 공유된 부모 객체 (shared parent object)에 오염된 값을 심어야 합니다. 하지만 라이브러리의 그 어떤 기능도 이를 허용하지 않습니다. 라이브러리가 가져오는 문서들은 엄격하게 파싱되며 허용 목록 (allowlist)을 통해 읽히기 때문에, 외부인이 무언가를 오염시킬 수 있는 경로는 없습니다. 다시 말해, 이 버그를 악용하려면 이미 프로세스 내부에서 본인의 코드를 실행하고 있어야 합니다. 그리고 이미 프로세스 내부에서 코드를 실행 중이라면, 보안 계층 전체를 그냥 교체해 버리면 그만입니다. 이미 집 안에 들어와 있다면 자물쇠는 의미가 없습니다.
따라서 실제 심각도는 이랬습니다: 기능이 출시되기도 전에 발견된, 수정할 가치는 있지만 작은 방어 심층 (defense-in-depth) 격차였습니다. 심각한 구멍도 아니었고, 결코 100개의 파일로 구성된 라이브러리를 구축해야 할 이유도 아니었습니다. 우리는 도달할 수 없는 문을 방어하기 위해 필요하지도 않은 라이브러리 전체를 과잉 설계 (over-engineered)했습니다. 그 주요 원인 중 큰 부분은 위에서 언급한 바와 같습니다: 도달 가능성 확인 (reachability check)도 없고 중단 조건 (stopping condition)도 없이, 관리되지 않은 채 실행된 과잉 모델 (overkill model) 때문이었습니다. 돌이켜보면, 그것을 혼자 실행하게 둔 것이 진짜 실수였습니다.
왜 아무도 잡아내지 못했는가
이 부분은 깊이 생각해 볼 가치가 있습니다. 왜냐하면 답이 "리뷰어들이 부주의했다"가 아니기 때문입니다. 그들은 부주의하지 않았습니다. 두 가지 별개의 구조적인 이유가 있었습니다.
1. 테스트와 명세 (spec)에 문제가 기술되어 있지 않았습니다. 테스트는 잘못된 값들, 즉 잘못된 형식의 URL, 잘못된 타입, 너무 큰 입력값 등을 확인했습니다. 하지만 객체의 형태가 어떠한지, 특정 값이 진정으로 해당 객체의 것인지 아니면 조용히 상속된 것인지는 전혀 기술하지 않았습니다. 실패할 규칙을 아무도 작성하지 않았기 때문에, 실패할 수 있는 테스트도 없었습니다. 명세에서 언급하지 않은 문제는 잡아낼 수 없습니다. 공정하게 말하자면, 모든 형태의 입력을 예측할 수는 없으며, "오염된 프로토타입 (polluted prototype)으로 테스트했는가?"라는 질문은 대부분의 팀의 체크리스트에 들어있지 않습니다. 바로 그렇기 때문에, 누군가가 테스트를 하기 전에 명세나 위협 모델 (threat model) 어딘가에 해당 클래스가 명시되어 있어야만 합니다.
2. 모든 AI 리뷰어들이 동일한 사각지대를 공유했습니다. 여러 모델이 독립적으로 검토를 수행했고, 그들은 해당 코드가 안전하다고 동의했습니다. 하지만 그들 모두 일반적인 방식인 깨끗한 객체(clean objects)를 사용하여 코드를 테스트했습니다. 단 하나도 공유된 부모(shared parent)를 먼저 오염(poison)시킨 뒤 테스트하지 않았습니다. 리뷰어들이 모두 동일한 가정을 할 때, 리뷰어의 수를 늘리는 것은 도움이 되지 않습니다. 그들은 단지 그 공백에 대해 함께 동의할 뿐입니다.
어떠한 자동화된 체크(automated checks)도 이를 잡아내지 못했습니다. 이를 잡아낸 것은 한 걸음 물러나 새로운 관점에서 하나의 단순한 질문을 던지는 것이었습니다: "이것이 실제로 공격받을 수 있는가?"
내가 어떻게 되돌렸는가
복구 과정은 코드보다는 거의 프로세스에 관한 것이었습니다:
- 무언가 잘못되었을 때를 알아차리세요. 매 라운드마다 커지는 변경 사항은 철저함이 아니라 계획의 부재입니다. 한 가지 사항에 대해 리뷰 라운드가 세 번 이상 지속되는 것은 이제 저에게 더 밀어붙이라는 신호가 아니라, 멈춰서 살펴봐야 한다는 신호입니다.
- 트립와이어(tripwire) 없이 큰 작업을 방치하지 마세요. 강력한 모델이 인간의 감시도 없고 "이상해지면 중단하라"는 조건도 없이 몇 시간 동안 계속 돌아가게 두는 것이, 4줄짜리 수정 사항이 100개의 파일로 변하게 만든 원인이었습니다. 방치된 실행에는 제한이 필요하며, 특히 무한한 작업을 끝까지 기꺼이 쫓아가는 끊임없이 목표 지향적인 모델의 경우, 무언가 이상해 보이는 즉시 인간에게 알릴 방법이 필요합니다.
- 코드를 수정하기 전에 설계를 먼저 수정하세요. 저는 100개 파일이 포함된 풀 리퀘스트(Pull Request)를 병합하지 않고 닫았습니다. 그것은 약 5시간 반 동안 열려 있었습니다 (PR #88, 공개 및 종료됨). 저는 실제 버그를 수정 사항을 되돌리면 실패하는 테스트와 누락되었던 단 하나의 실제 테스트를 포함하여, 아주 작은 5개 파일의 변경 사항으로 다시 잘라냈습니다.
- 구축하기 전에 경계(boundary)를 문서로 작성하세요. 나머지 작업에 대해서는, 무엇이 범위 외(out of scope)인지에 대한 솔직한 노트를 포함하여 실제 규칙이 무엇인지 명세(spec)에 먼저 작성한 다음 구현했습니다. 이제 리뷰는 계획을 라운드마다 발견하는 대신, 계획을 바탕으로 검토하게 됩니다. 이것은 들리는 것보다 어렵습니다. 때로는 경계를 넘어선 후에야 그 경계가 어디에 있어야 하는지 알게 되기도 합니다.
하지만 처음에 설정한 대략적인 경계라도 없는 것보다는 낫습니다.
- 분리하십시오 (Break it up). 통제 불능 상태였던 작업은 검토 가능한 세 개의 작은 변경 사항으로 나뉘었습니다. 세 가지 모두 깔끔하게 병합되었습니다.
진정한 수정 사항은 배포되었습니다. 통제 불능이었던 부분은 배포되지 않았습니다.
내가 틀렸던 점, 그리고 내가 추가하려는 규칙
내가 여기서 침착한 사람이었다고 가장하는 것은 하지 않겠습니다. 나는 상황이 계속 진행되도록 방치했습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기