내 스캐너는 버그의 93%를 놓쳤다 — 그리고 그것은 올바른 첫 번째 결과였다
요약
LLM을 활용한 AI 취약점 스캐너의 첫 번째 벤치마크 테스트 결과를 공유합니다. OWASP Benchmark를 대상으로 테스트한 결과, 정밀도는 0.60으로 높았으나 재현율이 0.07에 그쳐 실제 버그의 93%를 놓치는 한계를 보였습니다.
핵심 포인트
- LLM 기반 스캐너는 정밀도는 높지만 재현율이 매우 낮음
- OWASP Benchmark를 활용한 객관적 성능 측정 수행
- 정적 분석 규칙과 LLM을 결합한 하이브리드 아키텍처 구조
- 취약점 탐지를 위한 소스-싱크 및 오염 분석(Taint Analysis) 개념 적용
내가 산업 표준 벤치마크 (benchmark)를 대상으로 취약점 스캐너 (vulnerability scanner)를 처음 실행했을 때, 스코어러 (scorer) 보고서의 결론은 다음과 같았습니다:
$ python scripts/score_benchmark.py --findings out/java.findings.json \
--truth benchmark-java/expectedresults-1.2.csv
...
세 개의 숫자가 있으며, 각각의 의미는 다음과 같습니다. 정밀도 (Precision) 0.60 — 스캐너가 발생시킨 모든 경고 중 60%가 실제 버그를 가리켰습니다. 즉, 스캐너가 경고를 보냈을 때 대부분 맞았습니다. 재현율 (Recall) 0.07 — 벤치마크에 있는 모든 실제 버그 중 7%만을 찾아냈습니다. 내 스캐너가 다루는 네 가지 취약점 클래스 (vulnerability classes)에서, 벤치마크는 레이블이 지정된 777개의 실제 취약점을 포함하고 있습니다. 스캐너는 찾아내야 할 버그의 93%를 놓쳤습니다. F1 0.13 — 정밀도 (precision)와 재현율 (recall)을 하나로 결합한 점수 (조화 평균)로, 낮은 재현율 때문에 거의 바닥까지 떨어졌습니다.
나의 첫 번째 본능은 누군가 보기 전에 이를 수정하는 것이었습니다. 하지만 나는 대신 출력값을 저장하고, 그 숫자를 내 벤치마크 로그에 기록한 뒤 그대로 두었습니다. 왜냐하면 그 숫자는 결국 공개될 것이고, 이 글이 바로 그것을 공개하는 글이기 때문입니다.
맥락 (The Context)
지난 몇 달 동안 나는 AI에 깊이 몰두해 왔습니다 — 읽고, 만들고, 측정하면서 말이죠. 그 과정에서 나온 프로젝트 중 하나가 AI 취약점 스캐너 (AI vulnerability scanner)입니다. 한 문장으로 요약하자면 이렇습니다: 결정론적 정적 분석 규칙 (deterministic static-analysis rules)이 모든 탐색을 수행하고, LLM이 각 발견 사항 (finding)에 대해 이것이 실제 버그인지 아니면 오탐 (false alarm)인지 판단합니다. 전체 아키텍처 (architecture)는 별도의 글로 다룰 예정입니다. 이 글은 측정된 첫 번째 숫자에 관한 것입니다.
테스트 세트는 OWASP Benchmark입니다 — Java 보안 스캐너를 위한 표준 시험인 2,740개의 레이블이 지정된 Java 테스트 케이스입니다. 내 스캐너의 네 가지 취약점 클래스 (SQL injection, command injection, path traversal, XSS)에는 1,478개의 케이스가 있습니다: 777개의 실제 취약점과 스캐너가 오탐을 발생시키도록 의도적으로 설계된 701개의 케이스로 구성됩니다. 내가 비교하는 모든 도구 — Semgrep, CodeQL — 는 동일한 시험을 치르며, 동일한 스코어링 코드에 의해 점수가 매겨집니다. 모두에게 동일한 규칙이 적용됩니다.
이 내용이 처음인가요? 이 기사에는 세 가지 단어가 핵심입니다. **소스 (source)**는 신뢰할 수 없는 입력값이 프로그램에 들어오는 지점입니다 (
request.getParameter("id")— 공격자가 입력할 수 있는 모든 것). **싱크 (sink)**는 해당 입력값이 위험해지는 지점입니다 (executeUpdate(sql)— 이를 데이터베이스 명령으로 실행하는 것). 취약점 (vulnerability)이란 데이터가 전달 과정에서 정제되지 않은 채 소스에서 싱크로 흐르는 것을 의미합니다. 이렇게 흐르는 데이터를 오염된 (tainted) 데이터라고 부르며, 이를 추적하는 것이 바로 _오염 분석 (taint analysis)_입니다. 스캐너의 전체 작업은 바로 이러한 흐름을 찾아내는 것입니다. 이미 이 내용을 모두 알고 계신가요? 그렇다면 이 박스를 건너뛰셔도 좋습니다.
왜 0.07이 올바른 첫 번째 결과였는가
제가 실제로 실행했던 것은 스파이크(spike)였습니다. 의도적으로 최소화한 첫 번째 버전이었죠. 단 하나의 소스 패턴인 getParameter를 몇 개의 싱크에 연결하여 전체 파이프라인을 통해 엔드 투 엔드(end-to-end)로 밀어 넣었습니다. 즉, 2,740개의 테스트 케이스를 코드 그래프(code graph)로 파싱하고, 오염 쿼리(taint queries)를 실행하고, 결과(findings)를 생성한 뒤, 정답지와 비교하여 점수를 매기는 과정입니다.
스파이크의 목적은 결코 높은 점수를 받는 것이 아니었습니다. 스파이크의 목적은 단 하나의 단순한 질문에 답하는 것이었습니다. 기계 장치가 작동하기는 하는가?
그리고 주의 깊게 읽어보면, 그 보기 싫은 숫자가 그 질문에 답을 해주었습니다.
- 정밀도 (Precision) 0.60 — 스캐너가 경보를 울렸을 때, 그것은 대개 맞았습니다. 오염 엔진(taint engine)은 실제 흐름을 올바르게 추적하고 있었습니다.
- 재현율 (Recall) 0.07 — 버그의 93%를 놓쳤습니다. 엔진이 고장 난 것이 아니라, 엔진의 _어휘 (vocabulary)_가 너무 작았던 것입니다. 저는 문이 아주 많은 건물에서 단 하나의 문에 귀를 기울이고 있었던 셈입니다.
이것은 잘못된 아이디어가 아닙니다. 너무 좁은 소스 목록에 대한 정확하고 단순한 진단입니다. 제가 잘못된 계층(layer)에 몇 주를 투자하기 전에 내려진 진단이죠. 만약 첫 번째 숫자가 정밀도 0.10이었다면, 그것은 엔진의 문제였을 것이고, 이는 재구축(rebuild)을 의미했을 것입니다. 하지만 재현율 문제는 목록의 문제입니다. 목록은 수정 가능합니다.
왜 뻔한 선택들이 모두 틀렸는가
뻔한 선택 #1: 아무에게도 말하지 마라. 조용히 수정하고, 최종 결과만 발표하여 유능해 보이도록 하는 것입니다. 공개적으로 작업(building in public)하는 거의 모든 사람이 이 방식의 변형을 택합니다. 첫 번째 좋은 결과부터 시작되는 "하룻밤 사이에 이룬" 성공 그래프를 그리는 것이죠.
문제는 이 시리즈의 모든 결과가 저의 개인적인 벤치마크 (benchmark) 실행 결과에서 나온 숫자라는 점입니다. 여기에는 심판이 없습니다. 제가 결과물을 배포하기 전에 제 작업을 검증해 줄 제3자도 없습니다. 오직 저와, 점수를 매기는 스크립트, 그리고 여러분뿐입니다. 독자가 그런 숫자를 판단할 수 있는 방법은 단 하나뿐입니다. 바로 저자 본인에게 불리했던 결과들에 대한 그간의 기록입니다. 만약 제가 보여주는 모든 숫자가 승리(win)라면, 여러분은 그 어떤 것도 신뢰할 이유가 없습니다. 그래서 나쁜 숫자들도 함께 배포합니다. 그리고 그것들을 가장 먼저 배포하죠. 저는 나중에 Semgrep 및 CodeQL과의 정면 대결(head-to-head) 결과를 발표할 예정인데, 그때 제가 어떤 결과를 주장한다면 독자들이 "이 사람은 자신의 0.07을 공개했던 바로 그 사람이다"라고 생각하기를 바랍니다. 여기서 정직함은 미덕이 아니라 인프라 (infrastructure)입니다.
뻔한 수법 #2: 규칙을 무차별적으로 투하하기. 재현율 (recall)이 너무 낮다고요? 패턴을 추가하세요! 더 많은 이름을 매칭하고, 정규 표현식 (regex)을 완화하며, 모든 것에 오염 (taint)을 입히세요. 그러면 재현율은 올라갈 것입니다. 하지만 이는 정밀도 (precision)를 파괴할 것이며, 더 나쁜 것은 20개의 변경 사항을 동시에 적용한 후에는 어떤 변경 사항이 어떤 결과를 초래했는지 알 수 없게 된다는 점입니다. 여러분은 측정 가능한 시스템을 '느낌 (vibes)'에 의존하는 시스템과 맞바꾼 것입니다.
대신 제가 택한 방법은 더 느리고 지루한 방식이었습니다. 벤치마크의 실제 코드를 읽고, 코드가 실제로 무엇을 호출하는지 찾아내며, 코드가 사용하는 빈도 순서대로 소스 (sources)를 추가하고, 매 변경 사항마다 다시 측정하는 것이었습니다. 한 번에 하나의 변수만, 한 번의 변경에 하나의 숫자만 다루었습니다.
상승 (The Climb)
해결책 1: 벤치마크의 어휘를 학습하기 — 재현율 0.07 → 0.83
저는 벤치마크 코드가 실제로 어떤 입력 방식 (input methods)을 사용하는지 조사하며 파일 수를 집계했습니다: getRequestURI 724개 파일, getCookies 664개, getParameter 538개, getParameterValues 510개, getHeaders 400개, 그리고 그 아래로 이어지는 HTTP 요청 표면 (request surface)을 조사했습니다. 저의 급증했던 수치는 그 목록 중 정확히 단 하나의 항목만을 커버하고 있었습니다.
따라서 소스 (sources)는 하나의 공유된 정의가 되었습니다. 즉, 완전 정규화된 메서드 이름 (fully-qualified method names)에 대한 단일 정규 표현식 (regex)을 사용하여, 각 게터 (getter)가 공격자 제어 (attacker-controlled)가 가능한 타입에 결합되도록 했습니다. 여기에는 요약되어 있으며, 전체 규칙 테이블은 별도의 아티클에서 다룹니다:
// 오픈 소스 코드 분석 엔진인 Joern(Scala DSL)에서 실행되는 쿼리.
".*(HttpServletRequest|ServletRequest)\.(getParameter|getParameterNames|getHeader|"
+ "getHeaders|getCookies|getQueryString|getRequestURI|getInputStream|…)\b.*" +
...
(.*Cookie\.getValue.*는 쿠키의 getter만 매칭합니다. getValue에 대한 단순 매칭은 세상에 존재하는 모든 getValue와 매칭될 것입니다.)
이 단계 안에는 두 가지 다른 문제가 더 숨어 있었습니다:
- 벤치마크의 실제 SQL 인젝션 (SQL injection) 사례 중 117개는
java.sql을 전혀 사용하지 않습니다 — 대신 Spring의JdbcTemplate을 거칩니다. 새로운 싱크 (sink) 행 하나를 추가함으로써 SQLi 재현율 (recall)이 0.57에서 0.86으로 상승했습니다. - 6,060개의 XSS 싱크 (sink) 호출은 제 규칙 외의 이유로 인해 보이지 않았습니다. 분석 경로 (analysis path)에 서블릿 라이브러리가 없으면, 엔진은
response.getWriter()가 어떤 타입을 반환하는지 계산할 수 없으므로, 해당 호출들은 타입 기반의.*Writer.*패턴과 결코 매칭될 수 없었습니다. 해결책: 수신자 텍스트 (receiver text) — 즉, 코드에 문자 그대로 작성된response.getWriter()부분 — 이getWriter|getOutputStream과 매칭될 때도 싱크로 수락하도록 했습니다. 이 단 한 번의 변경으로 XSS 재현율 (recall)이 0.03에서 0.73으로 뛰었습니다.
새로운 점수: 정밀도 (precision) 0.53, 재현율 (recall) 0.83.
해결책 2: 남은 미검출 사례 130개 중 97개가 하나의 누락된 소스(source)를 공유함 — 0.83 → 0.95
여전히 130개의 실제 버그가 누락되어 있었습니다. 미검출 사례들을 벤치마크 코드와 비교(diff)해 본 결과, 그중 97개 — 남은 것의 4분의 3 — 가 제가 목록에 넣지 않았던 단 하나의 메서드인 getParameterNames()로부터 오염 (taint)을 전달받고 있었습니다.
왜 이것이 누락되었는지 이해하기는 쉽습니다. getParameter("id")는 사용자가 입력한 값 (value) 을 반환하며, 이는 명백히 위험합니다. 반면 getParameterNames()는 파라미터의 이름 (names) 을 반환하며, 이름은 데이터가 아닌 구조처럼 느껴집니다. 하지만 클라이언트 또한 이름을 선택할 수 있습니다. ?<script>alert(1)</script>=x는 어떤 클라이언트든 보낼 수 있는 쿼리 스트링이며, 이 경우 이름 (name) 은 값과 마찬가지로 공격자에 의해 제어될 수 있습니다 (attacker-controlled).
정규 표현식(regex) 내의 이름 하나. 이를 추가하자 즉시 93개의 실제 취약점이 복구되었습니다:
재현율 (recall) 0.83 → 0.95. (97개 중 나머지 4개는 두 번째의 별개 문제로 인해 차단되었습니다 — 이들은 Fix 3에서 다룹니다.) 그리고 여전히 저를 괴롭히는 부분은 이것입니다: 아무것도 충돌하거나, 경고를 보내거나, 잘못되어 보이지 않았다는 점입니다. 소스 (source)가 누락되면 조용히 (silently) 실패합니다. 라벨링된 벤치마크 (labeled benchmark)가 없었다면 저는 결코 알지 못했을 것입니다.
Fix 3: 오염 브리지 (The taint bridge) — 0.95 → 1.00
그 결과 37개의 누락이 남았습니다 — getParameterNames를 사용하지 않은 33개와 Fix 2에서 발생한 두 번째 문제가 있었던 4개 — 그리고 그 하나하나 모두에 .split(...)이 포함되어 있었습니다. 추측하는 대신, 저는 하나의 실패 사례에 대해 오염 체인 (taint chain)을 링크별로 측정했습니다: 분할(split)되는 변수는 소스 (source)로부터 도달 가능 (reachable)했습니다. split 호출 자체도 도달 가능했습니다. 그 결과에 대한 배열 인덱스 접근인 param.split(" ")[0]은 **도달 불가능 (not reachable)**했습니다. 오염 (taint)은 split을 통해 올바르게 흐르다가 인덱스 연산에서 끊겼습니다. 엔진은 해당 연산자에 대한 기본 규칙을 제공하며, 이를 재정의(overriding)해도 아무것도 바뀌지 않았습니다 — 격차는 엔진이 해당 연산자에 규칙을 적용하는 방식에 있었으며, 제가 설정으로 해결할 수 있는 영역이 아니었습니다.
그래서 저는 정직함을 유지하기 위한 한 가지 조건을 가진 브리지 (bridge)를 구축했습니다: split 스타일 호출에 대한 인덱스 접근은 해당 인덱싱되는 배열 자체가 실제 소스 (source)로부터 도달 가능할 때만 추가적인 소스로 승격됩니다. 오염되지 않은 (untainted) 배열을 인덱싱하는 것은 오염되지 않은 상태로 유지됩니다 — 이 조건이 타겟팅된 수정 (targeted fix)과 무차별적인 과잉 오염 (blanket over-tainting) 사이의 차이점입니다. 37개의 누락이 모두 복구되었으며, 그 대가는 정확히 3개의 새로운 거짓 양성 (false positives)과 스캔당 약 84초의 추가 작업 시간이었습니다 — 재현율 (recall)과 정밀도 (precision)를 동시에 높인 수정이었습니다.
수치 (The Numbers)
측정된 변화를 하나씩 적용하며 올라온 전체 과정은 다음과 같습니다:
| 단계 (stage) | 정밀도 (precision) | 재현율 (recall) | F1 |
|---|---|---|---|
스파이크 (spike) (getParameter 전용) | 0.60 | 0.07 | 0.13 |
| ... | |||
| 미탐 (Zero false negatives) 없음. 네 가지 클래스 모두 재현율 (recall) 1.00을 달성했습니다 — SQL 인젝션 (SQL injection) 실제 버그 272개 중 272개 발견, 커맨드 인젝션 (command injection) 126개 중 126개, 경로 탐색 (path traversal) 133개 중 133개, XSS 246개 중 246개 — 이는 동일한 코드에 의해 측정된, 동일한 1,478개 사례에 대해 CodeQL이 달성한 것과 동일한 재현율입니다. 빌드 단계 없이, 단 7행의 규칙 테이블만으로 이루어낸 결과입니다. |
이제 불편한 부분입니다. 왜냐하면 이것은 출시 홍보글이 아니라 엔지니어링 로그이기 때문입니다. 정밀도 (precision) 0.56은 614개의 오탐 (false alarms)을 의미하며, 이 레이어는 벤치마크에 설계된 함정의 88%에 빠집니다 — 이는 기존의 어떤 도구보다도 높은 수치입니다 (직접 비교한 기사에서 전체 비교 데이터를 제공합니다). 변별력이 낮은 완벽한 재현율은 모든 것에 대해 "아마도"라고 말하는 도구와 불쾌할 정도로 맞닿아 있으며, 저는 독자가 저에 대해 그런 말을 쓰게 만들기보다 차라리 제가 직접 그 문장을 쓰는 편을 택하겠습니다.
당신이 제기하기 전에 미리 답변하는 세 가지 반론
"벤치마크 자체의 코드를 읽고 그것이 호출하는 것을 추가해서 재현율을 높였군요. 그것은 테스트 세트에 맞춘 튜닝 (tuning on the test set)입니다 — 재현율 1.00은 아무런 의미가 없습니다."
부분적으로 맞으며, 정확히 짚고 넘어갈 가치가 있습니다. 벤치마크는 공개되어 있으며, 여기서 측정되는 모든 도구에 대해 오픈북(open-book) 방식입니다 — 기존 도구들도 이에 맞춰 튜닝을 합니다. 하지만 실제 수정 사항이 무엇이었는지 보십시오: 벤치마크 전용 해킹이 아니라, 표준 HttpServletRequest 입력 표면인 getHeader, getCookies, getRequestURI를 다루었습니다. 단 하나의 특수 사례인 벤치마크 자체 래퍼 클래스 (wrapper class)는 위의 정규 표현식에 공개되어 있습니다. 그리고 함정들은 조작(gaming)이 아님을 증명합니다: 저는 설계된 오탐 사례의 88%에 빠졌으며, 이는 CodeQL보다 더 나쁩니다 — 만약 제가 정답지에 맞추려 했다면, 가장 먼저 수정했을 숫자가 바로 이것이기 때문입니다.
Recall 1.00이 솔직하게 주장하는 바는 더 좁은 범위입니다. 즉, 특정 어휘(vocabulary)가 주어졌을 때, 엔진은 해당 어휘를 사용하는 그 어떤 것도 놓치지 않는다는 것입니다. 그리고 그 어휘를 구축한 루프(코드가 실제로 호출하는 것을 조사하고, 개수에 따라 소스를 추가하며, 모든 변경 사항 이후에 다시 측정하는 과정)는 실제 코드베이스를 온보딩(onboard)할 때 사용하는 것과 동일한 루프입니다. 실제 저장소(repository)로의 일반화(Generalisation)는 아직 측정되지 않았습니다. 제가 이를 측정하게 되면, 결과가 좋든 나쁘든 공개될 것입니다.
"왜 그냥 CodeQL을 사용하지 않나요? 재현율(recall)은 같으면서 정밀도(precision)는 더 높을 텐데요." 맞습니다. 그리고 직접 비교한 기사에서도 해당 내용을 평문으로 명시하고 있습니다. 그럼에도 불구하고 이 프로젝트가 존재하는 두 가지 이유가 있습니다. 첫째, 이 발견 레이어(discovery layer)는 빌드 단계(build step)가 필요 없는 7행짜리 규칙 테이블이라는 점입니다. 반면 CodeQL은 수년간 모델링된 라이브러리를 가져오며 사용자의 빌드를 요구합니다. 둘째, 정밀도(precision) 문제는 의도적인 과잉(surplus)입니다. 발견 단계에서 과잉 보고를 해야만 판단자(judge) — 즉, LLM 레이어 — 가 제거할 재료를 가질 수 있기 때문입니다. 통제된 테스트에서 제가 시도한 가장 우수한 판단자 모델은 실제 버그의 2%를 희생하는 대가로 오탐(false alarms)의 절반 — 최근 검증된 실행에서는 52% — 을 제거했습니다. 설계를 결정짓는 비교 기준은 기존 방식(incumbents) 대비 판단자의 출력값이며, 그 수치는 결과가 어떻든 공개될 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기