적대적 패치 루프는 취약점이 '정말 봉인됐다'를 어떻게 판정하는가
대부분의 보안 도구는 '수정 제안'에서 멈춥니다. 그다음 질문 — 이 패치가 실제로 공격을 막는가 — 은 사람에게 떠넘겨집니다. 적대적 패치 루프는 그 질문에 답하기 위해 만들어졌고, 답의 형식은 '통과/실패'가 아니라 재현 가능한 증거입니다.
글 플롯팩토리 보안 엔지니어링팀
FlawDetector 엔진 · 리서치
재스캔은 증명이 아니다
정적 분석 도구의 표준 워크플로는 이렇게 끝납니다. 결함을 찾고, 수정을 제안하고, 개발자가 패치를 머지하면, 다음 스캔에서 그 결함이 사라진 것을 확인하고 티켓을 닫습니다. 문제는 마지막 단계가 아무것도 증명하지 않는다는 점입니다. 결함을 만든 판단과 결함이 사라졌다는 판단이 같은 엔진, 같은 규칙, 같은 관점에서 나옵니다. 시험 문제를 낸 사람이 자기 답안을 채점하는 구조입니다.
실제로 우리가 고객 리포지토리에서 반복적으로 관찰한 실패 유형은 세 가지였습니다. 첫째, 시그니처만 비껴간 패치. 탐지 규칙이 걸었던 문자열 패턴만 바꿔서 스캐너는 조용해졌지만 공격 경로는 그대로인 경우입니다. 둘째, 부분 완화. 가장 흔한 진입점 하나만 막고 같은 취약점을 공유하는 두 번째 핸들러는 남겨둔 경우입니다. 셋째, 위치 이동. 검증 로직이 상위 계층으로 올라가면서 우회 가능한 경로가 새로 생긴 경우입니다. 세 경우 모두 재스캔은 깨끗합니다.
스캐너가 조용해진 것과 공격이 실패하는 것은 서로 다른 사건입니다. 대부분의 파이프라인은 앞의 것을 측정하고 뒤의 것을 보고합니다.
루프의 다섯 단계
적대적 패치 루프는 CI 안에서 두 개의 에이전트를 맞붙입니다. AI 레드팀은 확정된 결함 하나를 받아 실제로 악용을 시도하고, AI 블루팀은 그 시도를 무력화하는 최소 패치를 만듭니다. 한 라운드는 다음 다섯 단계로 구성되고, 각 단계는 다음 단계가 소비할 산출물을 남깁니다.
- 01
공격 — 레드팀이 익스플로잇 계획을 세운다
결함 위치, 호출 그래프, 진입점 목록을 받아 다단계 공격 시퀀스를 구성합니다. 산출물은 실행 가능한 요청 시퀀스와, 성공을 판정할 관측 조건(응답 본문, 상태 코드, 사이드 이펙트)입니다.
- 02
침투 탐지 — 격리 환경에서 실행한다
일회용 컨테이너에 리포를 띄우고 시퀀스를 실행합니다. 관측 조건이 충족되면 그 결함은 '이론적 위험'이 아니라 '재현된 익스플로잇'으로 승격되고, 트레이스가 저장됩니다.
- 03
패치 생성 — 블루팀이 최소 변경을 만든다
블루팀은 결함 코드가 아니라 익스플로잇 트레이스를 입력으로 받습니다. 목표는 '이 트레이스를 실패시키는 가장 작은 변경'이며, 리팩터링이나 스타일 변경은 명시적으로 금지됩니다.
- 04
재공격 — 같은 공격과 변이 공격을 다시 던진다
패치가 적용된 트리에 원본 시퀀스를 재실행하고, 이어서 변이 스위트를 던집니다. 여기서 하나라도 통과하면 그 결과가 다음 라운드 블루팀의 입력이 됩니다.
- 05
봉인 검증 — 기능이 그대로인지 확인한다
보안 관문을 통과한 패치는 마지막으로 기존 테스트 스위트와 정상 입력 응답 비교를 거칩니다. 공격을 막으면서 기능을 깨뜨린 패치는 봉인으로 인정하지 않습니다.
적대적 패치 루프의 봉인 판정 세 관문
'봉인됨(sealed)'은 마케팅 문구가 아니라 세 개의 불리언 조건이 모두 참일 때만 붙는 라벨입니다. 하나라도 거짓이면 상태는 봉인이 아니고, 판정 로그에는 어떤 관문에서 왜 걸렸는지가 남습니다.
| 관문 | 통과 조건 | 실패했을 때 |
|---|---|---|
| 원본 익스플로잇 재현 | 1라운드에서 성공했던 공격 시퀀스를 3회 재실행해 전부 실패 | 패치가 공격 경로를 건드리지 못한 것. 트레이스를 붙여 블루팀에 재작성 요청 |
| 변이 스위트 | 인코딩·컨텍스트·체인 세 계열에서 파생한 변이 12건이 모두 실패 | 시그니처만 막은 패치. 통과한 변이를 컨텍스트에 넣어 다음 라운드로 |
| 회귀 및 동작 동등성 | 기존 테스트 전부 통과 + 정상 입력에 대한 응답이 패치 전과 동일 | 보안은 됐으나 기능이 깨진 것. 변경 범위를 좁혀 재시도 |
세 관문은 순차적으로 평가되며, 앞 관문이 실패하면 뒤 관문은 실행하지 않습니다.
변이 익스플로잇은 어떻게 만들어지는가
변이 스위트의 목적은 단 하나, 블루팀이 문자열을 막았는지 경로를 막았는지 구분하는 것입니다. 원본 익스플로잇이 성공한 트레이스를 씨앗으로 삼아 세 계열의 변형을 생성합니다.
- 인코딩 계열 — URL 이중 인코딩, 유니코드 정규화 차이, 널 바이트 삽입, 대소문자 혼용처럼 같은 의미를 다른 바이트로 표현하는 변형입니다. 정규식 기반 필터는 대부분 여기서 무너집니다.
- 컨텍스트 이동 계열 — 동일한 페이로드를 다른 진입점(다른 라우트, 배치 API, 웹훅 수신부, 관리자 전용 핸들러)으로 옮겨 던집니다. 부분 완화를 잡아내는 관문입니다.
- 체인 재구성 계열 — 다단계 공격의 순서를 바꾸거나 중간 단계를 다른 수단으로 대체합니다. 예를 들어 인증 우회 → 파일 읽기 순서를 인증 우회 → 경로 조작 → 파일 읽기로 늘리는 식입니다.
변이 개수는 기본 12건이며, 결함 유형에 따라 가중치가 다릅니다. 인젝션 계열은 인코딩 변이 비중이 높고, 인가 결함은 컨텍스트 이동 변이가 절반 이상을 차지합니다. 변이 생성은 결정적(deterministic)으로 동작하도록 시드를 고정해서, 같은 결함에 대해 같은 스위트가 재생성됩니다. 감사 상황에서 '그때 무엇을 던졌는가'를 다시 보여줄 수 있어야 하기 때문입니다.
봉인되지 않을 때 — 완화와 에스컬레이션
루프는 무한히 돌지 않습니다. 기본 상한은 4라운드이고, 라운드마다 블루팀은 이전 실패의 전체 컨텍스트를 이어받습니다. 상한에 도달하면 세 가지 종료 상태 중 하나가 확정됩니다.
- sealed — 세 관문 모두 통과. 패치 diff와 검증 로그가 함께 PR로 열립니다.
- mitigated — 원본 공격은 실패하지만 변이 일부가 남음. 남은 변이의 재현 절차가 그대로 첨부됩니다.
- escalated — 최소 패치로 해결되지 않는 구조적 결함. 설계 변경이 필요하다는 판단과 함께 사람에게 넘어갑니다. 인증 모델 자체가 잘못된 경우가 대표적입니다.
2.3
봉인까지 평균 라운드
Critical·High 결함 기준, 중앙값 2라운드
88.4%
4라운드 내 봉인률
루프에 진입한 결함 기준
8.1%
완화로 종료
위험도는 낮아졌으나 변이 잔존
3.5%
사람에게 에스컬레이션
대부분 설계·인증 모델 문제
판정 로그 읽는 법
모든 루프는 하나의 판정 객체로 종료됩니다. 이 객체는 대시보드 표시, 게이트 판단, 감사 리포트 생성에 모두 같은 형태로 쓰입니다.
{
"finding": "FD-2026-07-1183",
"cwe": "CWE-89",
"severity": "critical",
"verdict": "sealed",
"rounds": 2,
"gates": {
"original_exploit": { "reproduced": false, "attempts": 3 },
"mutation_suite": {
"passed": 12,
"failed": 0,
"families": ["encoding", "context-shift", "chain-reorder"],
"seed": "fd-1183-a"
},
"regression": { "tests": 412, "failed": 0, "behavioural_diff": "none" }
},
"patch": { "commit": "a1f7c02", "files": 1, "added": 6, "removed": 2 },
"sealed_at": "2026-07-09T04:12:38Z"
}읽을 때 가장 중요한 필드는 verdict가 아니라 gates입니다. verdict만 보면 통과/실패지만, gates를 보면 무엇을 근거로 그렇게 판정했는지가 드러납니다. mutation_suite.seed가 남아 있기 때문에 6개월 뒤 감사에서도 동일한 변이 스위트를 재생성해 같은 결과를 확인할 수 있습니다.
CI에 게이트로 붙이기
루프의 실질적인 가치는 머지 게이트로 쓸 때 나옵니다. 심각도별로 요구 상태를 다르게 잡는 것이 일반적인 구성입니다. Critical과 High는 sealed를 요구하고, Medium 이하는 mitigated까지 허용하는 식입니다.
name: security-gate
on: [pull_request]
jobs:
flawdetector:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: floatfactory/flawdetector-action@v1
with:
api-key: ${{ secrets.FLAWDETECTOR_API_KEY }}
mode: loop # 탐지 후 레드팀 vs 블루팀 루프까지 실행
max-rounds: 4
seal-gate: critical,high # 이 심각도는 sealed 여야 머지 허용
allow-mitigated: medium,low
comment-pr: true # 판정 로그를 PR 코멘트로
# 종료 코드
# 0 게이트 통과
# 12 seal-gate 심각도에 mitigated 잔존
# 13 escalated 결함 존재 — 사람 리뷰 필요게이트를 처음 켤 때는 seal-gate: critical부터 시작하는 편을 권합니다. High까지 한 번에 올리면 기존 부채가 한꺼번에 머지를 막아서, 대개 게이트를 끄는 결정으로 이어집니다. 부채는 별도 백로그로 빼고 게이트는 신규 코드에만 적용하는 방식이 정착률이 훨씬 높았습니다.
우리가 측정하는 것, 믿지 않는 것
루프를 운영하면서 내부적으로 추적하는 지표는 '자동으로 고친 건수'가 아닙니다. 그 숫자는 코드베이스가 지저분할수록 커지기 때문에 품질과 상관이 약합니다. 대신 다음 세 가지를 봅니다.
- 봉인 리드타임 — 결함이 확정된 시점부터 sealed 판정까지의 시간. 팀이 실제로 체감하는 유일한 속도 지표입니다.
- 재개방률 — sealed 판정 후 90일 안에 같은 결함이 다시 재현되는 비율. 판정 기준이 느슨해지면 가장 먼저 움직입니다.
- 변이 통과율 — 1라운드 패치가 변이 스위트에서 걸리는 비율. 블루팀이 문자열을 막고 있는지 경로를 막고 있는지를 보여주는 대리 지표입니다.
반대로 신뢰하지 않는 지표도 명확합니다. 패치 수용률은 개발자가 diff를 읽지 않고 승인해도 올라가고, 평균 심각도 감소폭은 라벨링 정책만 바꿔도 개선됩니다. 보안 도구의 지표는 도구 스스로 조작할 수 있는지부터 확인해야 합니다.
정리
적대적 패치 루프의 설계 원칙은 한 문장으로 줄일 수 있습니다. 판정하는 쪽과 수정하는 쪽을 분리하고, 판정의 근거를 재현 가능하게 남긴다. 레드팀과 블루팀을 나눈 것도, 변이 스위트에 시드를 고정한 것도, sealed와 mitigated를 다른 라벨로 유지하는 것도 전부 같은 원칙에서 나왔습니다.
보안 자동화에서 어려운 부분은 수정 코드를 만드는 일이 아닙니다. 그 수정이 충분한지 스스로 판단하지 않는 것, 그리고 충분하지 않을 때 그렇다고 말하는 것입니다.
자주 묻는 질문
- 적대적 패치 루프는 실제 서비스에 공격을 시도하나요?
- 아닙니다. 모든 익스플로잇 시도는 해당 커밋으로 빌드한 일회용 격리 컨테이너 안에서만 실행되며, 외부 네트워크는 차단됩니다. 운영 환경이나 스테이징 환경에는 어떤 요청도 전달되지 않습니다.
- 봉인(sealed)과 완화(mitigated)의 차이는 무엇인가요?
- 봉인은 원본 익스플로잇 재현 실패, 변이 12건 전부 실패, 회귀·동작 동등성 통과라는 세 관문을 모두 충족한 상태입니다. 완화는 원본 공격은 막았지만 변이 중 일부가 여전히 통과하는 상태로, 위험은 낮아졌지만 공격 경로가 남아 있음을 뜻합니다.
- 루프가 만든 패치를 자동으로 머지해도 되나요?
- 권장하지 않습니다. 기본 동작은 판정 로그와 diff를 붙인 PR을 여는 것이며, 승인은 사람이 합니다. 다만 sealed 판정이 붙은 패치는 회귀 테스트와 동작 동등성 검증을 이미 통과했기 때문에, 리뷰가 확인해야 할 범위가 크게 줄어듭니다.
- 루프 한 번에 시간이 얼마나 걸리나요?
- 결함 하나당 라운드당 수십 초 수준이며, 봉인까지 평균 2.3라운드가 걸립니다. 전체 리포지토리 스캔의 중앙값은 41초이고, 루프는 확정된 Critical·High 결함에 대해서만 선택적으로 실행되도록 구성할 수 있습니다.