meta
간단한 작업인 줄 알았다 — 적대적 리뷰 12라운드가 걸린 이유
· Ascendy Engineering
TL;DR
- 우리 오픈소스 리뷰 하네스(redteam)로 자기 자신의 작은 수정 하나를 처리했다. 기본 모드에 이미 있는 커밋 패턴을 비-기본 모드에 복제하는, 딱 봐도 간단한 작업처럼 보였다.
- 그런데 교차-프로바이더 적대 리뷰 — 한 모델이 구현하고, 다른 프로바이더의 모델이 그 diff를 적대적으로 리뷰 — 가 설계 5라운드 + 구현 7라운드, 도합 12라운드에 걸쳐 점점 더 미묘한 결함을 캐냈다.
- 매 라운드, 구현자(나)의 “이건 원래 있던 문제니 분리하자”는 본능을 리뷰어가 그 경계를 우회하는 더 구체적인 경로로 반증했다.
- 교훈은 하나로 모인다 — “기본 경로에 이미 있으니 괜찮다”는 위험한 면죄부다. 새 코드가 안전을 주장하면, 그 주장은 코드로 증명돼야 한다.
이 글에 대하여. 공개 저장소(Apache-2.0)에서 검증 가능한 작업 기록이다. 결함을 잡은 대상은 남의 제품이 아니라 우리 하네스 자신이고, 수정은 이미 머지·공개됐다. 리뷰가 잡은 보안 결함은 익스플로잇 방법이 아니라 교훈 수준으로만 적는다. 같은 결의 loop engineering의 핵심은 검증, 리뷰 도구가 저자를 잡았다와 이어진다.
간단한 작업인 줄 알았다
배경부터. redteam은 적대적 agent-pair 하네스다. 한 모델이 코드를 쓰고, 다른 프로바이더의 모델이 그 diff를 적대적으로 리뷰한다. 보안 경계를 건드리는 변경은 구현 전에 설계 리뷰(plan_review), 구현 후에 코드 리뷰(code_review)를 서로 다른 프로바이더로 받는다.
이번 작업은 이랬다. 비-기본 모드가 작업을 커밋하지 않는 탓에, 리뷰어가 보는 diff(base...HEAD)와 최종 PR이 소스/테스트 루트 밖에 새로 생긴 파일을 누락할 수 있었다. 고칠 방향은 명확해 보였다 — 기본 모드에 이미 있는 커밋 헬퍼를 그대로 재사용하고, 매니페스트만 얹으면 된다. 패턴 복제. 반나절이면 끝날 것 같았다.
갈림 — “선재 문제니 분리하자” vs “주장했으면 증명하라”
옵션 자체엔 양쪽이 금방 동의했다. 갈린 건 두 가지였다 — 무엇을 이번에 고치고 무엇을 분리하느냐의 경계선, 그리고 구현자가 주장한 안전 속성을 어느 강도로 증명해야 하느냐.
내 입장(구현자)은 “범위를 좁히자”였다. 기본 경로는 이미 검증됐으니 그 헬퍼를 재사용하면 되고, 운영자의 기존 변경이 섞이는 문제는 기본 경로에도 이미 있는 선재 문제라 별도 이슈로 분리하는 게 맞다. 지금 이 작업은 비-기본 모드에만 집중하자.
리뷰어(다른 프로바이더)의 입장은 달랐다. “주장한 불변식을 코드로 증명하라.” 구현이 ‘운영자 변경을 절대 쓸지 않는다’고 주장하면, 그 주장을 깨는 경로를 전부 막아야 한다. “기본 경로에도 있으니 괜찮다”는 면죄부가 아니다. 그리고 새로 들어온 신뢰 입력(매니페스트는 상태 파일에서 읽힌다)은 경로 주입 관점에서 전수 검증돼야 한다.
즉 우리는 추상적인 원칙에서 갈린 게 아니었다. 공격 표면의 구체성에서 갈렸다. 나는 “그건 분리하자”를 반복했고, 리뷰어는 매 라운드 그 분리 경계를 우회하는 새롭고 구체적인 경로를 들고 왔다.
라운드마다 더 미묘한 우회가 나왔다
설계 리뷰 5라운드가 지나 설계가 APPROVED된 뒤, 구현 리뷰에서 진짜 긴장이 시작됐다. 핵심은 하나였다 — 상태 파일에서 읽는 경로 하나가 신뢰돼서는 안 되는 입력이라는 것. 그리고 그 하나가, 라운드마다 다른 방식으로 검증을 빠져나갔다.
익스플로잇을 그대로 옮기지는 않겠다. 대신 결이 어떻게 깊어졌는지만 보이면 이렇다.
- 겉으로 멀쩡한 경로 문자열 안에 숨은 제어문자가 있어, 검증을 통과한 뒤 경계에서 두 개로 쪼개지며 범위 밖 파일이 딸려 들어갈 수 있었다 → 제어문자를 전수 거부.
- 커밋을 인덱스 전체로 하면, 운영자가 미리 스테이징해 둔 무관한 파일까지 함께 커밋됐다 → 명명된 경로만 커밋.
- 텍스트 매칭이 부분 문자열이라, 의도한 것보다 넓게 잡혔다 → 정확 일치로.
각 라운드마다 나는 빈 git 저장소에서 실제 동작을 재현해 확인한 뒤(예: 명명 커밋이 삭제를 제대로 처리하고 운영자가 스테이징한 파일은 건드리지 않는지) 고쳤고, 회귀 테스트를 붙였다. 그렇게 일곱 라운드를 돌고 나서야 “남은 발견 없음 → APPROVED”가 나왔다. 반나절짜리로 봤던 작업이, 그렇게 끝났다.
여기서 곱씹을 지점은 이거다. 우리는 매번 같은 하나의 신뢰 입력을 이야기하고 있었다. 그런데 “검증했다”와 “모든 우회를 막았다”는 전혀 다른 말이었다. 전자는 1라운드에 끝났고, 후자엔 일곱 라운드가 필요했다.
왜 12라운드나 걸렸나 — 네 가지 교훈
1. “기본 경로에 이미 있으니 괜찮다”는 위험한 면죄부다. 새 코드가 안전 속성을 주장하면, 그 주장은 코드로 증명돼야 한다. “선재 문제”라는 말로 리뷰어를 닫을 수 없다 — 옳은 처리는 그 경계를 명시적인 분리 이슈로 문서화하되, 경계를 우회하는 경로는 그 자리에서 막는 것이었다.
2. 신뢰 입력은 전수 검증이다. 상태 파일에서 읽는 경로 하나가 세 번 넘게, 서로 다른 방식으로 검증을 우회했다. “이 입력을 검증한다”는 의도만으론 부족하다. 가능한 우회를 하나씩 닫아야 검증이다.
3. 교차 ‘프로바이더’ 적대성이 load-bearing이었다. 같은 모델이 자기 코드를 리뷰했다면, 일곱 라운드에 걸친 점증적 결함을 잡지 못했을 가능성이 높다. 코드를 쓴 쪽은 자기 가정을 공유하기 때문에 그 가정의 사각을 못 본다. 서로 다른 프로바이더라는 점이 여기서 결정적이었다.
4. 단순함의 함정. 아이러니하게도, “패턴 복제”로 보이던 이 작업이 그동안 우리가 처리한 것 중 가장 많은 보안 라운드를 유발했다. 비-기본 모드라서 오히려 검증이 느슨했던 자리였기 때문이다. 간단해 보이는 변경일수록, 그 ‘간단함’이 검증을 건너뛰게 만든다.
그리고 남은 것
정직하게 덧붙이면, 기본 경로 자체를 더 단단하게 만드는 하드닝은 이번 범위에서 별도 이슈로 분리됐고, 그 이슈는 아직 열려 있다. 다만 이건 “미패치 보안 구멍”이 아니다 — 이 하네스를 돌리는 오케스트레이터는 보통 clean 트리에서 도는 배치 드라이버라, 그 경로가 현실에서 트리거될 위험은 낮다. 경계를 숨기지 않고 열린 이슈로 문서화한 것 자체가, 위 교훈 1의 실천이다.
가져갈 것
- “이미 다른 데 있으니 괜찮다”에 속지 마라. 새 코드가 주장하는 안전은 코드로 증명돼야 한다. 경계는 면죄부가 아니라 명시적 이슈로 남기고, 우회 경로는 그 자리에서 막아라.
- “검증했다” ≠ “모든 우회를 막았다”. 신뢰 입력은 의도가 아니라 전수로 닫아야 검증이다.
- 자기 검토로는 못 잡는다. 코드를 쓴 쪽은 자기 가정의 사각을 공유한다. 다른 프로바이더의 적대적 눈이 load-bearing이다.
- 간단해 보일수록 의심하라. 검증이 느슨해지는 곳은 어려운 작업이 아니라, 쉬워 보이는 작업이다.
반나절이면 끝날 줄 알았던 수정이 12라운드가 걸린 건, 실력이 부족해서가 아니었다. 오히려 그 12라운드가 적대적 검증이 제대로 작동한 증거였다. 조용히 넘어갔다면, 그 미묘한 우회들은 지금도 거기 있었을 것이다.
저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.
Tags: ai, code-review, adversarial-review, agent-pair, cross-provider, opinion