정상 경로가 계속 작동하는지 확인하는 것만으로는 보안 제어를 검증할 수 없다 — 코드가 실제로 프록시를 경유하고 있다는 걸 증명하는 유일한 방법은 작동할 수 없는 프록시를 지정해 놓고 전부 예상대로 깨지는지 확인하는 것이다.
배경
Sentinel(4편에서 다룬 AI 보안/QA 플랫폼)은 스캔 트래픽을 설정 가능한 SOCKS5 프록시를 통해 라우팅한다 — 타겟에 직접이 아니라 특정 네트워크 경로를 통해서만 도달해야 할 때 필요한 기능이다. AI 에이전트가 적대적 입력을 어떻게 처리하는지 점검하는 테스트 모듈 하나에서, 같은 감사 과정 중 서로 다른 두 가지 문제가 드러났다 — 하나는 조용한 프록시 우회였고, 다른 하나는 빠른 모듈들이 눈에 띄는 이유 없이 타임아웃되게 만드는 스케줄링 버그였다.
버그 1 — 쓰이지 않던 프록시
이 모듈의 HTTP 호출 지점들을 proxy=로 grep해보는 일상적인 점검에서, 아홉 곳이 프록시 파라미터를 전혀 지정하지 않은 채 외부 요청을 보내고 있다는 게 드러났다 — 같은 코드베이스의 자매 모듈이 모든 호출에서 일관되게 proxy=target.proxy if target.proxy else None을 쓰고 있는 것과 대조됐다.
# 수정 전 — 이 모듈의 아홉 개 호출 지점이 이런 모습이었음
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=5)
# 수정 후
async with httpx.AsyncClient(proxy=target.proxy or None) as client:
response = await client.get(url, timeout=5)
직접 도달 가능한 타겟에서는 이 버그가 보이지 않는다 — 스캔은 잘 되고, 그저 설정된 것과 다른 네트워크 경로를 탔을 뿐이다. 이건 프록시 경유가 반드시 필요한 조건(IP 은닉, 제한된 네트워크 접근)일 때만 문제가 되는데, 바로 그런 상황이 눈에 안 보이는 우회가 가장 위험한 경우다 — 도구는 정상 스캔을 보고하면서, 사실은 자신을 제약해야 할 라우팅 정책을 조용히 무시하고 있었던 것이다.
단순히 수정을 적용하는 게 아니라 증명하기: target.proxy를 의도적으로 존재하지 않는 SOCKS5 포트로 지정하고 스캔을 재실행했다. 수정 전이었다면 이 설정은 아무 상관이 없었을 것이다 — 아홉 개 호출 지점이 이걸 무시하고 실제 타겟에 직접 연결해서 성공했을 테니까. 수정 후에는 열여섯 개 모듈 전부가 가짜 프록시 포트를 가리키는 연결 오류로 실패했다. 이 실패 자체가 증명이다 — 코드가 이제 진짜로 당신이 준 주소를 통해 라우팅을 시도하고 있다는 뜻이지, 그걸 우회해서 그럴싸해 보이는 성공을 반환하는 게 아니라는 뜻이다. 이후 진짜 프록시로 다시 지정해서 재실행하니 회귀 없음이 확인됐다 — 수정 전과 같은 결과가, 이번엔 올바른 경로를 통해 나왔다.
버그 2 — 비동기 스케줄러를 조용히 막고 있던 동기 호출
같은 모듈에서 발견된 다른 증상: 개별 테스트 실행에서 특정 빠른 작업들(보통 실제 작업 시간이 3~4초)이 무작위로 60초 데드라인에서 타임아웃됐는데, 어떤 게 실패할지 일관된 패턴이 없었다.
원인은 이 모듈의 다른 열세 개 비동기 함수가 공유해서 쓰는 헬퍼 함수 하나였는데, 그 함수 자체가 블로킹 HTTP 호출을 하는 평범한 동기 함수로 작성돼 있었다.
# 수정 전 — 비동기 코드에서 호출되는 동기 함수, 블로킹 클라이언트 사용
def _make_probe(url):
for _ in range(4):
response = httpx.get(url, timeout=5) # 블로킹 호출
...
Python의 asyncio는 기본적으로 단일 스레드에서 실행된다. 명목상 "비동기"인 코드 안의 동기적이고 블로킹되는 네트워크 호출은 제어권을 이벤트 루프에 돌려주지 않는다 — 그 블로킹 호출이 걸리는 시간만큼 전체 루프를 그냥 얼려버린다. 최악의 경우 5초짜리 블로킹이 네 번 연속으로 이어질 수 있다. 같은 루프에 스케줄된 다른 모든 작업 — 빠르고 무관한 모듈들까지 포함해서 — 그 뒤에 얼어붙은 채 대기한다. 자기 작업에는 3.5초밖에 필요 없던 모듈이, 단지 이 함수의 최대 20초짜리 블로킹 구간 뒤에 우연히 스케줄됐다는 이유만으로(여러 동시 프로브에 걸쳐 반복되면서) 60초 데드라인을 놓칠 수 있었다.
# 수정 후 — 진짜 비동기, 제어권을 이벤트 루프에 돌려줌
async def _make_probe(url):
async with httpx.AsyncClient() as client:
for _ in range(4):
response = await client.get(url, timeout=5)
...
검증
- 스케줄링 수정 후 실제 타겟을 대상으로 전체 모듈 스위트를 두 번 재실행: 두 번 다 열여섯 개 모듈 전부에서 "module failed" 오류 0건 — 수정 전에는 간헐적으로 실패하던 것과 대비됐다.
- 위에서 설명한 프록시 수정 검증을 재실행(가짜 프록시 → 확실한 연결 실패, 진짜 프록시 → 완전한 성공, 수정 전 결과와 일치).
- 전체 회귀 테스트 스위트: 통과 건수 변화 없음, 두 수정 다 무관한 동작을 바꾸지 않았음을 확인.
성격이 매우 다른 두 버그 클래스지만 같은 원칙이 깔려 있다: 보안 통제를 "정상 경로가 여전히 작동하는가"로 검증하지 말고, 그 통제가 실패하는 모드를 눈에 보이게 만들어서 검증해야 한다. 프록시 우회는 도달 가능한 타겟에서는 보이지 않는다 — 코드가 실제로 프록시를 경유해 라우팅하고 있다는 걸 증명하는 유일한 방법은, 절대 작동할 수 없는 프록시를 지정하고 모든 게 예상대로 깨지는지 확인하는 것뿐이다. 별개로, 비동기 코드 안에 숨은 동기 호출로 인한 스케줄링 버그는 원인과 전혀 닮지 않은 증상(무작위로 발생하는 무관한 타임아웃)을 만들어낸다 — 이 수정은 타임아웃되는 함수 자체만 들여다봐서는 안 되고, "무엇이 타임아웃되는 대상과 같은 이벤트 루프를 공유하는가"를 추적해야만 가능했다.
첫 댓글을 남겨보세요