자율 침투 테스트 에이전트가 침해 사실을 주장만 하는 것이 아니라 증명하도록 가르치기
요약
자율 침투 테스트 에이전트가 셸 획득 여부를 판단할 때 발생하는 오탐(False-positive) 문제를 해결하기 위한 기술적 접근법을 다룹니다. 챌린지-응답 논스(Challenge-response nonce) 방식을 도입하여 타겟이 실제로 코드를 실행했음을 증명하도록 설계함으로써 에이전트의 신뢰성을 높이는 방법을 설명합니다.
핵심 포인트
- 단순 키워드 매칭 방식은 타겟의 위조된 출력에 의한 오탐 가능성이 높음
- 예측 불가능한 논스(Nonce)를 환경 변수로 주입하여 실행 증명 방식 도입
- 타겟이 생성한 결과값에 논스가 포함되어야만 침해 성공으로 인정
- Fail-closed 원칙을 적용하여 에이전트의 의사결정 신뢰도 확보
자율 공격 엔진 (autonomous exploitation engine)에서 가장 어려운 부분은 셸 (shell)을 획득하는 것이 아닙니다. 셸을 획득했다는 사실을 아는 것입니다.
LLM 기반 에이전트 (LLM-driven agent)를 타겟에 지정하면, 실제 숫자는 0임에도 불구하고 23/23 포트가 뚫렸다고 즐겁게 보고할 것입니다. 왜 그럴까요? uid=0(root)를 출력하는 서비스 배너 (service banner)나, 연결 시 그럴싸한 출력을 스트리밍하는 타르핏 (tarpit)은 부분 문자열 매칭 (substring matching)을 수행하는 모든 것들에게 성공과 똑같이 보이기 때문입니다. 낙관주의는 자동화된 공격 (automated offense)의 기본 실패 모드입니다.
이것은 제가 오늘 권한 게이트가 있는 자율 침투 테스트 엔진 (authorization-gated autonomous pentest engine)인 HALO에 병합한 작업들과, 신뢰 문제를 해결한 단 하나의 아이디어에 대한 이야기입니다: 타겟이 당신의 코드를 실행했음을 스스로 증명하게 만드는 것입니다.
오탐 (false-positive)의 함정
순진한 침해 확인 방식은 키워드 검색입니다:
def breached(output: str) -> bool:
return "uid=" in output or "root@" in output
이러한 토큰들은 실제로 아무것도 실행하지 않은 타겟에 의해 위조될 수 있습니다. 더 나쁜 것은, 성공 휴리스틱 (success heuristic)이 틀리면 보고서, "어떤 포트가 점유되었는지"에 대한 상태, 다음에 선택할 익스플로잇 (exploit) 등 모든 하위 결정이 그 거짓말을 상속받는다는 점입니다. 결국 당신은 자신 있게 틀리는, 확신에 찬 엔진을 갖게 됩니다.
해결책: 챌린지-응답 논스 (challenge–response nonce)
인증 (authentication)에서 가장 오래된 기술을 빌려옵니다. 각 시도 전에 오케스트레이터 (orchestrator)는 예측 불가능한 토큰을 생성하고 이를 환경 변수 (environment variable)로 주입합니다. 익스플로잇은 타겟이 해당 정확한 토큰을 다시 에코 (echo)하도록 요구됩니다:
import os
def make_nonce() -> str:
# 예측 불가능하여 타겟이 추측할 수 없음
return os.urandom(12).hex()
전달 페이로드 (delivery payload)는 개방된 채널을 통해 논스 (nonce)를 알리고, 침해 게이트 (breach gate)는 일치하는 구조화된 라인을 포함하는 결과만을 신뢰합니다:
HALO-EVIDENCE nonce=9f2c1a7b4e level=shell uid=0 host=victim exit=0
_EVIDENCE = re.compile(r"HALO-EVIDENCE nonce=(\S+) level=(\S+)")
def breach_confirmed(output: str, ok: bool, *, nonce: str) -> bool:
m = _EVIDENCE.search(output or "")
return bool(ok and m and m.group(1) == nonce)
tarpit 스트리밍 uid=0(root)은 이 실행의 nonce를 포함한 라인을 생성할 수 없습니다. 즉, 우리의 명령을 받은 적이 없다는 뜻입니다. Fail closed(실패 시 차단) 방식을 적용하면, 거짓 양성(false positives)은 사라집니다. 이제 게이트는 대상(target)이 조작할 수 없는 무언가에 고정되었습니다.
대상 불가지론적(target-agnostic) 전달 사다리
애초에 페이로드(payload)를 전달할 수 없다면 증명은 가치가 없습니다. 실제 호스트들은 인터프리터(interpreter)가 다르거나 송신 규칙(egress rules)이 다른 등 일관성이 없습니다. 따라서 전달 방식은 표준 라이브러리(stdlib) 소켓만을 사용하여 우아하게 성능이 저하되는(degrades gracefully) 사다리 구조를 가집니다.
- 리버스 쉘 (Reverse shell) — 대상이 일시적인 리스너(ephemeral listener)로 다시 접속합니다. nonce를 알리고
/bin/sh를 반환합니다. - 바인드 쉘 (Bind shell) — 송신(egress)이 차단된 경우, 대상이 쉘을 바인딩하면 우리가 접속합니다.
- 블라인드 콜백 (Blind callback) — 상호작용 가능한 채널이 남아있지 않은 경우, 대상은 단순히 다시 접속하여 nonce만 보냅니다. 이는 사용 가능한 쉘이 없더라도 코드 실행(code execution)을 여전히 증명합니다.
각 단계(rung)는 사용 가능한 첫 번째 인터프리터(bash /dev/tcp, python3, perl, nc)를 스스로 선택하므로, 대상이 192.0.2.10이든 192.0.2.200이든 동일한 프리미티브(primitive)가 작동합니다. 처음 확인된 단계가 승리하며, 특정 박스(box)에 종속되지 않습니다.
잠긴 박스 내부로 독립형 익스플로잇(self-contained exploit) 배송하기
큐레이션된 익스플로잇은 강화된 podman 샌드박스(sandbox) 내에서 실행됩니다: --read-only, --cap-drop=ALL, 메모리/pids 제한, 그리고 단 하나의 PoC 파일만 마운트됩니다. 마지막 제약 조건에서 문제가 발생했습니다. 리팩토링 과정에서 각 PoC가 공유 전달 모듈을 임포트(import)하도록 만들었는데, 이는 단일 파일 마운트 환경 내부에는 존재하지 않는 것이었습니다. 테스트는 통과했지만(레포지토리 루트에서 임포트하므로), 실제 샌드박스에서는 ModuleNotFoundError가 발생했습니다.
해결책은 배송 시점에 번들링(bundling)하는 것이었습니다. 공유 모듈의 소스를 배송되는 파일 내에 인라인(inline)으로 포함시키고, 형제 임포트(sibling import)를 제거한 뒤, 최상단에 from __future__를 올렸습니다. 레포지토리는 DRY(Don't Repeat Yourself) 원칙을 유지하고, 실제로 실행되는 아티팩트(artifact)는 독립형(self-contained)이 됩니다. 이를 잡아낸 회귀 테스트(regression test)는 패키지를 의도적으로 경로(path)에서 제외한 상태로 서브프로세스(subprocess)에서 번들을 실행합니다. 따라서 수정 전에는 실패(red)하고 수정 후에는 성공(green)하게 됩니다. 다시 한번, 느낌(vibes)보다는 증명(proof)입니다.
구축 방법: subagent(하위 에이전트) 주도형 TDD
전체 기능은 규율 있는 루프를 통해 출시되었습니다: 하나의 새로운 에이전트가 테스트 우선(test-first) 방식으로 작업을 구현하면, 두 번째 에이전트가 명세(spec)에 따라 이를 검토합니다. 발견된 사항들은 수정 및 재검토 과정을 거치며, 마지막으로 전체 브랜치 검토(whole-branch review)를 통해 작업 간의 경계(cross-task seams)를 훑습니다. 이 마지막 단계에서 실제 오류를 잡아냈습니다. 하나는 PoC(Proof of Concept) 내의 오류 경로로, 안전하게 차단(fail closed)하는 대신 예외를 던질(throw) 수 있는 문제였습니다. 또한, shlex.quote로 강화할 가치가 있는 인용되지 않은 쉘 결합(unquoted shell splice) 문제도 식별했습니다. 사소한 부분들이지만, 운영 환경(production)에서는 치명적인 문제들입니다.
최종 결과: 283개 테스트 통과(green), 모든 작업 검토 완료, "이 정도면 됐지" 식의 타협 없음.
보너스 레슨: 공개하기 전에 깨끗이 정리하세요
릴리스를 위해 리포지토리(repo)를 준비하던 중, 추적된 파일들에 대해 git grep을 실행하자 피스처(fixtures)와 내부 계획 문서에 실제 네트워크 주소와 호스트 식별자가 포함되어 있는 것이 발견되었습니다. 더 심각한 것은 일부가 이미 푸시(pushed)된 히스토리에 포함되어 있었다는 점입니다. 깨끗한 워킹 트리(working tree)만으로는 도움이 되지 않습니다. git은 절대 잊지 않기 때문입니다.
해결 방법: 실제 값을 RFC 5737 문서용 IP(192.0.2.0/24, 198.51.100.0/24)로 교체한 후, 단일한 깨끗한 커밋으로 다시 작성하여 강제 푸시(force-push)했습니다. 이 IP 범위는 예시가 실제 인프라와 충돌하지 않도록 특별히 존재합니다. 보안 도구를 작성한다면 지금 바로 이 습관을 채택하십시오: 모든 피스처에는 문서용 IP 범위를 사용하고, 비밀 정보(secrets)와 범위 파일(scope files)은 커밋 #1부터 git-ignored 처리하십시오.
핵심 요약 (Takeaways)
- 출력을 신뢰하지 마세요 — 당신이 발행한 토큰을 신뢰하세요. 챌린지-응답(Challenge–response) 방식은 "uid=0이라고 말했다"를 "내 논스(nonce)를 그대로 에코(echo)했다"로 바꿉니다.
- 추측이 아닌 단계(ladder)를 통해 전달하세요. Reverse → bind → blind-callback 방식은 실제 호스트의 복잡한 현실을 포괄합니다.
- 실제로 배포하는 아티팩트(artifact)를, 실제로 실행되는 환경에서 테스트하세요.
- 희망 사항이 아닌
git grep으로 정리하세요. 그리고 다음에는 정리할 것이 없도록 RFC 5737 IP를 사용하세요. - 증명(Proof)이 낙관주의(optimism)를 이깁니다. 게이트(gate)를 먼저 구축하세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기