왜 '외부에서 보이는 자산'인가
경찰청 집계에 따르면, 2026년 상반기 랜섬웨어 피해 보고 건수는 123건으로, 반기 기준으로 역대 최다였습니다. 감염 경로 중 가장 많은 것은 여전히 VPN 장비입니다.[1]
공격의 시발점은 대부분 인터넷을 향하는 무언가, 즉 패치가 적용되지 않은 VPN 장치나 열어둔 상태로 방치된 원격 데스크톱, 관리가 안 되는 서브 도메인 등입니다. 이러한 자산을 공격자보다 앞서 지속적으로 목록화(재고 조사)해 두는 것이 Attack Surface Management (ASM)의 개념이며, OASM은 이를 구동하는 플랫폼입니다. 핵심 코어는 OSS 형태로 공개되어 있습니다.
직접 만들어보니, ASM의 어려움은 '스캐너를 실행하는 것' 자체에 있지 않았습니다.
- 성격과 출력 형식이 제각각인 스캐너들을 안전하고 대량으로 지속적으로 구동하는 것
- 그 결과를 중복 없이 '추적 가능한 데이터'로 통일하는 것
- 최근에는, 이 데이터 위에서 AI 에이전트가 작업을 수행하게 하는 것입니다. 게다가 임의적인 행동을 하지 못하도록 제한하면서 말입니다.
이 세 가지가 핵심입니다. 아래 순서대로 살펴보겠습니다.
전체 개요
Core API (NestJS)가 업무 로직과 오케스트레이션을 담당하고, Go 워커가 실제 스캔을 전담합니다. 스캐너 본체는 '커넥터(Connector)'라고 부르는 Docker 이미지를 사용하며, 워커가 작업(Job) 단위로 구동시킵니다.
1. 워커를 Pull 방식으로 설계하다
워커는 Core API에 gRPC로 연결하여 작업을 스스로 가져옵니다.
free := cap(semaphore) - len(semaphore)
...
reserved := reserveSlots(free)
...
핵심은, 작업을 가져오기 전에 사용 가능한 슬롯을 예약한다는 점입니다. 예약한 만큼 NextBatch로 한 번에 묶어서 가져오므로, 수신된 작업들은 모두 즉시 시작할 수 있습니다. '가져왔지만 실행할 자리가 없어 대기시키는' 식의 작업은 발생하지 않습니다.
폴링 간격(Polling interval)은 여유 있을 때는 1초부터 배수로 늘려 5초까지 늘리고, 작업이 흐르고 있을 때는 100ms 간격으로 집중적으로 가져옵니다.
Pull 방식을 사용하면 워커에서 Core API로 나가는 아웃바운드 통신만으로 구동할 수 있습니다. 따라서 워커를 사내 네트워크나 클라우드의 다른 리전(Region)에 배치해도 수신 포트를 열 필요가 없습니다.
그래도 Push 방식이 필요한 경우
'실행 중인 작업을 취소하고 싶다'와 같은 상황에서는 서버에서 워커로 알림을 보내는 경로가 필요합니다. 그래서 작업 가져오기와 별개로 양방향 스트림(bidirectional stream)을 하나만 추가했습니다. proto 파일에는 다음과 같은 주석을 남겼습니다.
// Identity, job pull and results deliberately keep their own RPCs;
// this stream only adds the push channel core-api lacked.
rpc Connect(stream WorkerToCore) returns (stream CoreToWorker);
모든 것을 스트림으로 통합하지 않은 이유는 간단합니다. 스트림이 끊어졌을 때 작업 가져오기 기능까지 영향을 받게 하고 싶지 않았기 때문입니다.
워커의 신원(Identity)
워커는 처음에 일회용 join 키로 Join하고, 반환된 워커 토큰을 0600으로 파일에 저장합니다. 이후에는 이 토큰을 gRPC 메타데이터에 담아 통신합니다.
하나의 호스트에서 여러 워커를 구동할 경우, 토큰 파일을 최대 64개의 슬롯으로 나누어 잠금(Lock) 처리합니다. 만약 두 개의 워커가 같은 토큰으로 복구되면 동일한 ID를 사용하게 되므로, 후술할 고아 컨테이너(Orphan container) 정리 과정에서 서로의 컨테이너를 삭제해 버릴 수 있기 때문입니다.
2. 스캐너는 컨테이너에 가두다
매니페스트 구동 방식 (Manifest-driven)
커넥터 목록은 별도의 리포지토리인 oasm-connectors에서 동기화된 매니페스트로 관리합니다(발췌).
{
"slug": "nuclei",
"image": "ghcr.io/oasm-platform/connector-nuclei:0.1.0",
...
}
이미지 해결은 Core API의 역할입니다. 작업을 할당할 때 이미지 이름, CPU, 메모리, 타임아웃, 설정값 등을 담아 전달하고, 워커는 매니페스트를 전혀 읽지 않습니다. 도착한 job.image만 그대로 구동하면 됩니다.
그 결과, 스캐너의 추가나 버전 업그레이드는 '매니페스트 동기화'만으로 충분하며, OASM 자체의 배포나 워커 업데이트가 필요 없습니다.
컨테이너 시작 설정
워커가 Docker Engine API를 사용하여 커넥터를 시작할 때의 설정입니다.
hostConfig := &container.HostConfig{
Resources: hostResources(opts, d.cpuQuotaUnsupported),
ExtraHosts: []string{"host.docker.internal:host-gateway"},
...
}
func DefaultIsolation() IsolationOpts {
return IsolationOpts{ReadOnlyRootFS: true, NoNewPrivileges: true, NetworkMode: "bridge", PidsLimit: 1024}
}
루트 파일 시스템은 읽기 전용이며, 쓰기가 가능한 것은 /tmp의 tmpfs뿐입니다. no-new-privileges를 적용하고, PID 수, 메모리, CPU에도 상한을 설정했습니다. 메모리는 Memory와 MemorySwap에 같은 값을 넣어 스왑으로 빠져나가지 않도록 했습니다.
커넥터로부터의 콜백 통신
컨테이너는 시작되면 워커의 :26276에서 gRPC 양방향 스트림(bidirectional stream)으로 연결해 옵니다.
message ConnectorMessage { oneof message { Register register = 1; Result result = 2; Done done = 3; } }
message WorkerMessage { oneof message { RegisterAck register_ack = 1; ExecuteJob execute = 2; Cancel cancel = 3; } }
service ConnectorService { rpc Connect(stream ConnectorMessage) returns (stream WorkerMessage); }
첫 번째 메시지는 반드시 Register이며, 여기에는 해당 실행 전용의 일회성 토큰이 들어갑니다. 컨테이너에는 환경 변수로 이 토큰만 전달되며, 워커는 실행 ID와 토큰이 완전히 일치할 경우에만 수락합니다. 다른 컨테이너가 다른 실행인 척 결과를 보내는 것은 불가능합니다.
Done을 받아도 스트림은 닫히지 않으며, 컨테이너는 웜 풀(warm pool)로 돌아갑니다. 동일한 이미지의 다음 작업은 이 컨테이너를 재사용하므로 시작 비용이 들지 않습니다. 반대로, 스트림이 EOF나 오류로 끊긴 컨테이너는 폐기합니다.
고아 컨테이너 정리
워커가 다운되면, 시작된 컨테이너가 남아 있을 수 있습니다. 따라서 모든 컨테이너에 oasm-managed=true와 oasm.worker_id 레이블을 붙여두고, 시작 시 다음 규칙으로 정리합니다.
- 종료된 것은 삭제한다.
- 실행 중이며 자신의
worker_id가 붙어 있는 것은 강제적으로 삭제한다 - 실행 중이며 다른 워커의 것은 건드리지 않는다.
이는 단일 호스트에서 여러 워커가 동작하더라도 서로의 컨테이너를 지우지 않기 위한 경계 설정입니다.
3. 파이프라인은 YAML 기반 DAG
도메인을 등록할 때 실행되는 정찰 흐름(偵察の流れ)은 GitHub Actions 스타일의 YAML로 정의합니다.
on:
target: [domain.create, domain.re-scan]
jobs:
...
needs가 충족된 단계부터 순서대로 작업을 만들고 배포합니다. 이전 단계에서 발견된 서브도메인은 자산으로 등록되므로, 다음 단계는 이를 대상으로 작업을 만듭니다. HTTP 프로빙과 스크린샷은 "호스트:포트" 단위로 작업을 생성하며, 스크린샷은 httpx가 생존을 확인한 서비스만 촬영합니다.
ジョブレジストリ
트리거는 on:의 값을 그대로 JSONB에서 검색하여 대조하고, 주기적 실행에는 BullMQ의 repeatable job을 사용합니다.
FOR UPDATE로 데드락이 발생하는 경우에 대한 논의
워크플로우를 진행하는 처리는 실행 중인 워크플로우의 행을 잠근(lock) 후 다음 단계를 할당합니다. 여기서 락 종류를 잘못 지정하면 데드락(deadlock)이 발생합니다. 코드에 남겨진 주석이 그대로 설명으로 되어 있어 인용하겠습니다.
// `FOR NO KEY UPDATE`, not `FOR UPDATE`: dispatching a step inserts job
rows whose FK check takes a FOR KEY SHARE lock on this very row, and
// FOR UPDATE conflicts with it — that deadlocks the two connections
...
작업(job) 행을 INSERT하면, 외부 키 검사 과정에서 부모 행에 FOR KEY SHARE 락이 걸립니다. 만약 부모 행을 FOR UPDATE로 잡고 있다면, 이 두 가지가 충돌합니다. FOR NO KEY UPDATE를 사용한다면 FOR KEY SHARE와는 경합하지 않기 때문에, 동시 실행의 직렬화(serialization)는 유지하면서 데드락을 피할 수 있습니다. PostgreSQL의 행 락 경합표를 한 번 살펴보면 막히는 상황을 줄일 수 있습니다.
결과 가져오기는 큐에 '참조'만 담는다
워커로부터 받은 결과는 먼저 오브젝트 스토리지(object storage)에 저장하고, BullMQ에는 { workerId, jobId, resultRef }와 같은 참조 정보만 쌓습니다. 포트 스캔이나 URL 탐색 결과는 수 메가바이트(MB)에 달할 수 있기 때문에 Redis에 본체를 담지 않도록 했습니다. 데이터 가져오기 측은 재시도 3회, 지수 백오프(exponential backoff)를 사용합니다.
취약점은 같은 것을 여러 번 감지해도 1건으로 처리하고 싶기 때문에, 핑거프린팅(fingerprinting)을 이용해 UPSERT 합니다.
return crypto.createHash('md5')
.update(`${args.name}-${args.assetId}-${args.toolId}${source}`)
.digest('hex');
알림은 새로 INSERT된 행에 대해서만 전송합니다. 매일 밤 스캔을 하더라도, 같은 취약점으로 인해 매일 슬랙(Slack)이 울리지는 않습니다.
4. AI 에이전트를 어디까지 신뢰할 것인가
OASM에는 AI Studio라는 보안 에이전트가 있습니다. Vercel AI SDK 위에 자체 루프를 구성했으며, 모델은 OpenAI, Anthropic, Gemini 등에서 선택할 수 있습니다.
AI Studio
편리한 반면, '스캐너를 구동하고 셸(shell)까지 실행할 수 있는 LLM'은 일반적으로 위험합니다. 따라서 설계의 대부분은 무엇을 못 하게 할 것인가에 사용되었습니다.
모드별로 도구 세트를 분리한다
질문에 답하는 것만 하는 Ask 모드에서는 워크스페이스 데이터를 읽는 도구만 전달합니다. 명령어 실행, 커넥터 실행, 서브 에이전트의 구동은 Agent 모드에서만 전달합니다. 프롬프트로 '사용하지 말라'고 요청하기보다는, 애초에 전달하지 않는 것이 가장 확실합니다.
승인 게이트(Approval Gate)
모든 도구 호출은 실행 전에 승인 검사를 거칩니다.
if (ctx.mode === AgentApprovalMode.PLAN && ctx.planFirst) return { allowed: false, planRequired: true };
if (ctx.needsTodo && (await ctx.needsTodo())) return { allowed: false, todoRequired: true };
if (ctx.mode === AgentApprovalMode.AUTO) return { allowed: true };
...
승인 모드는 세 가지가 있습니다.
- Manual: 아직 허가되지 않은 명령어는 매번 확인합니다.
- Plan: 계획을 한 번 승인하면, 나머지는 자동으로 실행합니다.
- Auto: 확인하지 않습니다.
Agent 모드에서는 계획(plan) 내의 어떤 단계도 in_progress 상태가 아니면 도구를 실행할 수 없습니다(needsTodo). 이는 '계획에 없는 작업을 갑자기 시작하는 것'을 구조적으로 방지하기 위함입니다.
승인 대기 메커니즘은 간단합니다. Promise를 생성하여 그 resolver를 Map에 넣고, UI에 approval-required
이벤트를 보내고 await합니다. 5분 동안 응답이 없으면 거부로 처리합니다. 타임아웃을 허용으로 간주하지 않는 것이 중요합니다. "이 대화에서는 이 도구를 항상 허용"이라는 기억은 Redis에 저장해 두고, Redis가 다운된 경우에는 이미 허용된 것으로 간주하지 않고 다시 묻습니다 (fail closed).
세부적인 이야기지만, URL을 가져오는 도구 retrieve_web_page는 읽기 전용으로 취급하지 않습니다.
이 도구가 가져온 URL은 데이터를 외부로 전송할 수 있습니다. 쿼리 문자열에 사내 데이터를 담아 외부 URL을 호출하는 것만으로도 정보가 외부에 유출될 수 있습니다. "읽기만 하는" 것처럼 보이는 도구라도, 외부로 나가는 경로가 될 수 있는 것은 승인 대상에 포함합니다.
서브 에이전트(Sub-Agent)에 대한 프롬프트 인젝션 방지 대책
메인 에이전트는 조사나 검증을 서브 에이전트에게 병렬로 맡길 수 있습니다. 이때, 서브 에이전트에게 전달하는 작업 문구는 "다른 에이전트가 작성한 데이터"로 취급합니다. 시스템 프롬프트 시작 부분에 이를 명시하고, 작업 문구 내의 태그는 무해화(neutralize)한 후에 전달합니다. 이는 스캔 대상 웹 페이지에 숨겨진 지침이 메인 에이전트를 거쳐 서브 에이전트에게 "명령"으로 도달하는 것을 방지하기 위함입니다.
서브 에이전트의 작업은 Postgres 테이블을 큐로 사용하며, FOR UPDATE SKIP LOCKED를 사용하여 데이터를 가져옵니다. 병렬 처리 수, 대화당 상한선, 타임아웃, 입출력 문자 수에도 제한이 있습니다.
큰 출력으로 컨텍스트(Context)가 넘치는 것을 방지
Nuclei나 katana의 출력을 그대로 LLM에 흘려보내면, 컨텍스트가 금방 채워집니다.
/** What one run_tool_connector / read_tool_output answer puts into the LLM context. */
const AGENT_TOOL_OUTPUT_PAGE = 8 * 1024;
/** Full output kept for read_tool_output: Redis only, never the database. */
...
모델에 전달되는 것은 맨 앞의 8KB뿐입니다. 나머지는 Redis에 1시간 동안 보관하며, 모델은 read_tool_output(runId, offset, limit, pattern)을 사용하여 필요한 부분을 페이지네이션하거나 grep하여 읽습니다. 이는 사람이 터미널에서 less를 사용하는 것과 같은 감각입니다.
명령어 실행 위치
셸 명령어는 Core API 서버에서는 실행하지 않고, 워커(Worker)로 보냅니다. 워커에서 agent_sandbox를 활성화하면, 세션마다 다음 설정을 가진 컨테이너를 생성하고 그 안에서 실행합니다.
hc := &container.HostConfig{
NetworkMode: "bridge",
ReadonlyRootfs: true,
...}
모든 케어빌리티(capability)를 제거하고, Docker 소켓을 마운트하지 않으며, 호스트의 환경 변수도 상속받지 않습니다. 환경 변수는 PATH와 HOME만 처음부터 재구성하여 env -i로 실행합니다. 만약 Docker를 사용할 수 없는 상태라면, 호스트에서 실행하는 것으로 폴백(fallback)하지 않고 오류 처리합니다.
헤드리스 브라우저와 SSRF (Server-Side Request Forgery)
에이전트는 워커 상의 Chromium도 조작할 수 있습니다. 세션마다 독립된 시크릿 컨텍스트(secret context)를 생성하며, 다운로드는 금지합니다.
샌드박스가 활성화된 워커에서는 브라우저 통신을 반드시 워커 내부의 포워드 프록시(forward proxy)를 거치게 합니다.
// Any host may be browsed, but
// only public addresses are dialed: the worker's local network is the
// platform's, not the tenant's. The check runs on the address each...
포인트는 호스트명 자체가 아니라, DNS를 조회한 후 실제로 연결하는 IP 주소로 판별한다는 것입니다. 호스트명 블랙리스트에서는 internal.attacker.example가 10.0.0.5
를 반환하는 DNS를 사용한 회피는 막을 수 없습니다. 사설 주소나 CGNAT 대역, IPv6에 내장된 사설 주소까지 차단하고 있습니다.
MCP는 읽기 전용
OASM은 MCP 서버로도 작동하여, 로컬 AI 클라이언트에서 자산 및 취약성 데이터를 가져올 수 있습니다. 다만 MCP를 경유하는 호출은 상단의 승인 흐름(approval flow)을 거치지 않기 때문에, 공개되는 도구는 읽기 전용에 한정됩니다.
// MCP 호출은 승인 흐름을 건너뛰므로, 명령이나 커넥터를 실행하거나,
// URL을 가져오거나 쓰는 기능은 여기에 등록될 수 없습니다.
승인을 거칠 수 없는 경로는 애초에 위험한 것을 두지 않습니다. 여기에서도 사고방식은 같습니다.
코드를 공개하는 이유
마지막으로, 설계라기보다는 사상(思想)의 문제입니다.
자산 목록과 취약성 목록을 합친 것은 공격자에게 거의 완벽한 지도와 같습니다. 어디에 무엇이 있고, 어디가 약한지. 그런 데이터를 다루는 도구이기 때문에, '내부에서 무슨 일이 벌어지고 있는지'를 누구나 확인할 수 있도록 하고 싶습니다. 이 글에서 내용을 이렇게까지 적을 수 있는 것도 코드가 모두 공개되어 있기 때문입니다.
사용 방식은 조직에 따라 선택할 수 있게 했습니다. 데이터를 사내에 보관해야 할 필요가 있다면, OSS 버전을 자체 인프라에 셀프 호스팅(self-host)할 수 있습니다. 운영상의 번거로움을 덜고 싶은 팀을 위해서는 매니지드 버전(Enterprise)을 준비했습니다. 자세한 내용은 oasm.io를 참고해 주십시오.
직접 실행해 보기
git clone https://github.com/oasm-platform/open-asm.git
cd open-asm
cp core-api/example.env core-api/.env
...
http://localhost:3000
에서 콘솔이 열립니다.
맺음말
ASM 도구를 만들면서 가장 크게 느끼는 것은, '스캔의 정확도'만큼이나 '스캔하는 메커니즘 자체를 공격 표면(attack surface)으로 삼지 않는 것'이 중요하다는 점입니다. 스캐너든 에이전트든 외부에서 들어오는 입력을 대량으로 다룹니다. 그렇기 때문에, 가두어 두기(confining) - 승인 절차 거치기(approval flow) - 기본적으로 거부하기(default deny)를 꾸준히 쌓아 올리고 있습니다.
설계에 대한 지적이나 개선안은 GitHub의 Issue나 Discord에서 대환영입니다. 도움이 될 것 같다고 생각하시면, 리포지토리에 ⭐을 눌러주시면 큰 힘이 됩니다.
경찰청 '레이와 8년 상반기에 걸친 사이버 공간을 둘러싼 위협 정세 등에 대해' https://www.npa.go.jp/publications/statistics/cybersecurity/index.html ↩︎
Discussion