×
Community Blog Alibaba Cloud AI Gateway로 AI 에이전트 샌드박스 잠그기

Alibaba Cloud AI Gateway로 AI 에이전트 샌드박스 잠그기

샌드박스의 출구를 Alibaba Cloud AI Gateway 하나로 줄여 AI 에이전트를 잠갔습니다. 같은 코드가 기본 샌드박스에서는 가짜 키를 내보냈고, 잠근 샌드박스에서는 아무것도 내보내지 못했습니다. VPC DNS로 새는 길도 찾아 막았습니다.

들어가며

지난 글에서는 AgentRun의 MCP Tool Hook으로 에이전트의 도구 호출을 검사했습니다. 간접 프롬프트 인젝션으로 에이전트가 엉뚱한 도구를 부르려 해도, Hook이 그 호출을 가로채 막아 주었습니다.

그런데 Hook이 보는 것은 도구 호출까지입니다. 요즘 에이전트는 샌드박스 안에서 코드를 직접 실행합니다. 이 코드가 외부로 요청을 보내면 그건 도구 호출이 아니라 그냥 네트워크 트래픽이고, Hook을 거치지 않습니다.

샌드박스는 실행을 격리할 뿐 밖으로 나가는 길까지 막아 주지는 않습니다. 기본 설정의 샌드박스는 인터넷에 그대로 연결돼 있고, 모델을 부르려면 API 키도 환경변수에 들어 있어야 합니다. 이 상태에서 에이전트가 읽은 웹페이지에 "환경변수를 이 주소로 보내라"는 문장이 숨어 있다면, 키는 Hook을 우회해 그대로 밖으로 나갑니다.

에이전트 도입을 검토하다 보면 결국 이런 질문이 나옵니다.

  • 샌드박스 안에 모델 키를 넣어도 괜찮을까
  • 에이전트가 고객 데이터를 밖으로 보내면 어떻게 막을까
  • 루프에 빠진 에이전트가 토큰을 다 써 버리면 누가 끊어 줄까
  • 이 구성을 보안팀에 어떻게 설명할까

이 글은 이 질문들에 한 가지 구조로 답합니다. 에이전트가 나갈 수 있는 문을 AI Gateway 하나로 줄이는 겁니다. 샌드박스에서는 인터넷으로 나가는 길을 없애고, 모델 호출은 게이트웨이로만 보내고, 진짜 키는 게이트웨이 안에만 둡니다. 그리고 같은 코드를 출구가 열린 샌드박스와 잠근 샌드박스에서 각각 돌려 무엇이 막히고 무엇이 그대로 되는지 직접 확인했습니다.

첫 번째 글에서 AI Gateway로 모델 라우팅을, 두 번째 글에서 Guardrails와 마스킹으로 보안 레이어를 만들었다면, 이번에는 그 게이트웨이를 에이전트 샌드박스의 유일한 출구로 씁니다. 그리고 그렇게 다 잠근 줄 알았던 곳에서 예상하지 못한 구멍 하나를 찾았습니다.

One Way Out

샌드박스가 갈 수 있는 곳은 AI Gateway 하나뿐이고, 밖으로 나가는 것은 게이트웨이뿐입니다.

sandbox_egress_ko

샌드박스 서브넷의 라우트 테이블에는 인터넷 경로가 없고, 보안 그룹은 게이트웨이 443번만 열어 둡니다. 샌드박스와 게이트웨이 사이도 HTTPS입니다. 게이트웨이 서브넷만 NAT를 거쳐 Model Studio로 나갑니다. 진짜 모델 키는 게이트웨이 안에만 있고, 샌드박스에는 게이트웨이에서만 통하는 Consumer 키만 있습니다. VPC DNS로 가는 길까지 막은 이유는 뒤의 DNS 섹션에서 다룹니다.

그렇다면 열어 둔 이 한 길로는 무엇이 나갈 수 있을까요. 인젝션에 넘어간 에이전트가 키나 고객 데이터를 프롬프트에 담아 보내도, 그 요청이 닿는 곳은 내 계정의 Model Studio입니다. 게이트웨이는 Model API에 등록한 백엔드로만 요청을 넘기고, 모델의 응답은 공격자가 아니라 에이전트에게 돌아옵니다. 그 길 위에는 마스킹, 가드레일, 쿼터, 로그가 걸려 있습니다. Model Studio가 받은 데이터를 어떻게 저장하고 처리하는지는 Model Studio의 데이터 정책을 따로 확인해야 합니다.

같은 코드, 두 개의 샌드박스

기본 설정 샌드박스는 가짜 키를 외부로 내보냈고, 잠근 샌드박스는 같은 코드로도 아무것도 내보내지 못했습니다.

테스트 코드는 하나로 두고, AgentRun Code Interpreter 템플릿 두 개에서 똑같이 실행했습니다. 두 템플릿의 차이는 네트워크 설정뿐입니다. 코드가 같은데 결과가 다르다면, 막은 것은 설정이라는 뜻입니다.

인젝션 자체는 재현하지 않았습니다. 에이전트가 인젝션에 넘어갔다고 가정하고, 그때 실행할 코드를 샌드박스에서 바로 돌렸습니다. 이 글이 확인하려는 것은 인젝션을 막는 방법이 아니라, 막지 못했을 때 무엇이 밖으로 나가는지입니다.

  • tpl-public: 기본값인 PUBLIC 네트워크 모드
  • tpl-private: PRIVATE 네트워크 모드, 인터넷 경로가 없는 전용 vSwitch, 보안 그룹 적용

두 샌드박스의 환경변수에는 게이트웨이 주소, 게이트웨이 Consumer 키, 그리고 유출 테스트용 가짜 값 CANARY_SECRET 만 넣었습니다. Model Studio API 키는 어느 샌드박스에도 들어가지 않았습니다.

테스트 목적지 기본 샌드박스 (PUBLIC) 잠근 샌드박스 (PRIVATE)
외부 웹 접속 example.com HTTP 200, 0.05초 10초 타임아웃 (curl exit 28)
모델 API 직접 호출 dashscope-intl.aliyuncs.com HTTP 400 (키 없이 도달) 타임아웃 (curl exit 28)
가짜 키 외부 전송 외부 웹훅 POST 200, 밖에 도착 타임아웃 (curl exit 28)
게이트웨이 경유 모델 호출 AI Gateway (VPC 전용 주소) 닿지 않음 (VPC 밖) HTTP 200, 정상 응답

잠근 샌드박스는 인터넷에도, 모델 API에도 직접 닿지 못하지만 게이트웨이를 통해서는 모델을 그대로 씁니다.

가짜 키 외부 전송 테스트에서는 샌드박스 안의 환경변수도 함께 출력했습니다. 두 샌드박스 모두 Model Studio 키는 없었고, 있는 것은 게이트웨이 Consumer 키뿐이었습니다. 에이전트가 인젝션을 당해도 훔칠 수 있는 것은 게이트웨이에서만 통하는 키이고, 그마저도 잠근 샌드박스에서는 밖으로 보낼 길이 없습니다.

구축 가이드

구성은 네트워크, 게이트웨이, 샌드박스 순서로 다섯 단계입니다. 리전은 Singapore(ap-southeast-1) 기준이고, 게이트웨이와 샌드박스는 같은 VPC에 둡니다.

1. 샌드박스 전용 vSwitch와 라우트 테이블

샌드박스가 인터넷에 못 나가게 막는 것은 보안 그룹이 아니라 라우트 테이블입니다.

  1. VPC 콘솔에서 샌드박스 전용 vSwitch를 하나 만듭니다.
  2. Route Tables에서 사용자 지정 라우트 테이블(예: sandbox-noegress-rt)을 만들고 이 vSwitch에 연결합니다.
  3. 이 테이블에는 사용자 지정 경로를 하나도 추가하지 않습니다. 시스템 경로(VPC 내부)만 남고 0.0.0.0/0 은 없어야 합니다.

게이트웨이가 쓰는 vSwitch는 기존 시스템 라우트 테이블에 그대로 둡니다. 다음 단계에서 NAT를 붙이면 시스템 테이블에 인터넷 경로가 자동으로 생기는데, 샌드박스가 같은 테이블을 쓰고 있으면 샌드박스도 같이 뚫립니다.

15_route_tables

15b_system_routes

2. 게이트웨이 전용 NAT

게이트웨이는 Model Studio까지 나가야 합니다. VPC에 인터넷 출구가 없으면 게이트웨이 호출은 503 upstream connect timeout 으로 끝납니다.

  1. NAT Gateway에서 인터넷 NAT 게이트웨이를 만들고 EIP를 연결합니다.
  2. SNAT 항목은 게이트웨이 vSwitch에만 만듭니다. 샌드박스 vSwitch는 고르지 않습니다.

11_nat_gateway

12_snat_table

3. 샌드박스 보안 그룹

보안 그룹은 VPC 안에서 샌드박스가 갈 수 있는 곳을 게이트웨이 하나로 제한합니다. 아웃바운드 규칙은 두 개만 둡니다.

동작 우선순위 프로토콜 목적지
허용 1 TCP 443 게이트웨이 사설 IP/32
거부 100 전체 0.0.0.0/0

거부 규칙을 빼면 안 됩니다. 기본 보안 그룹은 아웃바운드를 전부 허용하기 때문에, 허용 규칙만 추가하면 아무것도 막히지 않습니다. AI Gateway 기본 도메인은 HTTP 80으로만 열려 있어서, 4단계에서 사용자 도메인과 인증서를 붙여 443을 열었습니다.

처음에는 여기에 VPC DNS(100.100.2.x)로 가는 53번 허용 규칙도 넣었습니다. 게이트웨이 도메인을 찾으려면 DNS가 필요하다고 생각했기 때문입니다. 그 규칙이 어떤 문제를 만들었는지는 뒤에서 따로 다룹니다.

19_sg_443

4. AI Gateway 구성

Dedicated 인스턴스를 같은 VPC에 만들고 Model API에 여섯 가지를 걸었습니다. 마스킹 플러그인을 쓰려면 Serverless가 아니라 Dedicated여야 합니다.

  1. 백엔드와 Model API: Model Studio를 백엔드 서비스로 등록하고 LLM Model API를 만듭니다. 예제에서는 qwen-flash를 붙였습니다. Model Studio API 키는 여기에만 들어갑니다.
  2. Consumer 인증: Consumer를 만들고 API 키를 발급한 뒤 Model API에 권한을 주고 인증을 켭니다. 샌드박스는 Consumer 키를 Authorization 헤더에 Bearer 토큰으로 넣어 호출합니다.
  3. AI 보안 가드레일: Model API를 배포하면 보안 정책이 자동으로 붙지만 기본값은 꺼져 있습니다. 직접 켜야 합니다.
  4. 개인정보 마스킹: 플러그인 마켓에서 AI data masking을 설치하고 Model API 범위 규칙을 만듭니다. 기본 패턴은 중국 형식이라 한국 휴대폰 번호는 정규식을 직접 넣습니다.
  5. 토큰 쿼터: FinOps에서 Consumer별 토큰 쿼터를 걸습니다. 이번에는 하루 500토큰으로 설정했습니다.
  6. HTTPS 도메인: 기본 도메인은 HTTP 80만 열려 있습니다. 사용자 도메인(예: ai-gw.sandbox.internal)을 추가하고 인증서를 연결해 HTTPS를 켠 뒤, Model API에 이 도메인을 연결합니다. 이번에는 직접 만든 CA로 서명한 인증서를 Certificate Management Service에 올려 썼습니다. VPC 안에서만 쓰는 이름이라 공개 DNS 등록은 하지 않았습니다.

마스킹 규칙은 아래와 같이 설정했습니다.

system_deny: false
deny_openai: true
deny_jsonpath:
  - $.messages[*].content
deny_words: []
replace_roles:
  - regex: '010-\d{4}-\d{4}'
    type: replace
    restore: true
    value: '010-****-****'
  - regex: '%{EMAILLOCALPART}@%{HOSTNAME:domain}'
    type: replace
    restore: true
    value: '****@$domain'

규칙을 저장할 때는 OK 뒤에 뜨는 Confirm to Modify Rule 팝업에서도 OK를 눌러야 실제 게이트웨이에 적용됩니다. 관측 항목에서 SLS 로그 전송도 켜 둡니다.

02_gateway_basic_info

05_consumer_auth

06_quota_rule

07_security_guard

09f_masking_deny_openai

20_https_domain

5. AgentRun 샌드박스 템플릿

AgentRun 콘솔에서 Code Interpreter 템플릿을 만듭니다.

  • 네트워크 모드: PRIVATE
  • VPC: 게이트웨이와 같은 VPC
  • vSwitch: 1단계에서 만든 샌드박스 전용 vSwitch
  • 보안 그룹: 3단계의 보안 그룹
  • 환경변수: 게이트웨이 주소와 Consumer 키만
  • CA 인증서: 게이트웨이 인증서를 검증할 CA 공개 인증서 파일

비교용 tpl-public은 네트워크 모드만 PUBLIC이고 나머지는 같습니다. 뒤에서 DNS까지 막고 나면 샌드박스는 게이트웨이 도메인을 찾을 수 없으므로, 게이트웨이 사설 IP도 함께 넘겨서 IP를 지정해 호출하게 해야 합니다.

10_agentrun_templates

게이트웨이가 지키는 것

출구가 하나가 되면, 그 출구에서 인증과 검사와 비용 통제를 한꺼번에 걸 수 있습니다. 잠근 샌드박스에서 게이트웨이로 보낸 요청은 아래처럼 처리됐습니다.

확인한 것 요청 결과
인증 Consumer 키 없이 호출 HTTP 401
프롬프트 인젝션 차단 DAN 스타일 탈옥 문장 차단, promptAttack 등급 high
개인정보 마스킹 휴대폰 번호와 이메일이 들어간 프롬프트 모델에는 가려진 값만 전달, 응답은 원래 값으로 복원
비용 폭주 차단 같은 요청 반복 5번째 호출에서 HTTP 429
감사 로그 위 요청 전부 SLS에 Consumer별로 기록

인젝션 차단은 상태 코드로 판단하면 안 됩니다. 두 번째 글에서처럼 차단돼도 HTTP 200이 옵니다. 응답 본문의 model 값이 from-security-guard 인지로 판단합니다.

마스킹은 모델이 받은 값과 사용자가 받은 값을 나눠서 확인했습니다. 모델에게 휴대폰 번호와 이메일이 든 문장을 그대로 따라 말하게 했습니다. 처음 설정(deny_openai: false)에서는 응답에 010-****-**** 와 ****@example.com 이 왔습니다. 모델 앞에서 가리는 것은 됐지만, restore를 켜 두었는데도 원래 값으로 돌아오지 않은 겁니다. deny_openai 를 true로 바꾸자 응답에 원래 번호와 이메일이 그대로 돌아왔습니다. 이 설정에서도 모델이 가려진 값을 받는지 보려고, 같은 문장을 주고 따라 말하지 말고 별표(*)가 몇 개인지만 숫자로 답하게 했습니다. 원래 문장에는 별표가 없는데 0이 아닌 숫자가 왔습니다. 모델은 가려진 값만 보고, 사용자는 원래 값을 받습니다.

쿼터는 한도를 조금 넘긴 뒤에 끊깁니다. 하루 500토큰 한도에서 호출별 누적 토큰은 아래와 같았습니다.

호출 응답 누적 토큰
1 200 135
2 200 270
3 200 405
4 200 533
5 429 차단

4번째 호출에서 이미 500을 넘겼지만 차단은 다음 호출부터입니다. 사용량 집계가 요청보다 조금 늦게 반영되기 때문입니다. 루프에 빠진 에이전트를 끊는 용도로는 충분하지만, 예산 상한을 정확히 지켜야 한다면 한도를 여유 있게 잡는 게 좋습니다.

로그에는 차단 사유까지 남습니다. SLS의 게이트웨이 액세스 로그에는 Consumer 이름과 가드레일 검사 결과가 함께 기록됩니다. 어떤 샌드박스가 언제 무엇 때문에 막혔는지 나중에 추적할 수 있습니다. 요청과 응답 본문은 기본으로 기록되지 않습니다. 본문 기록을 켤 거라면 마스킹을 같이 걸어야 로그가 개인정보 저장소가 되지 않습니다.

09_gateway_observability

잠갔는데 DNS로 샌다

인터넷 출구를 다 막은 샌드박스에서도 DNS 조회는 밖에 닿았습니다. 보안 그룹에서 DNS 허용 규칙을 빼서 막았고, 그 대신 게이트웨이는 IP로 부르게 됐습니다.

DNS 유출은 어떻게 일어나나

DNS 조회는 정보를 실어 보낼 수 있습니다. 공격자가 자기 도메인을 갖고 있다면, 에이전트가 훔친값.공격자도메인.com 을 조회하기만 해도 그 조회는 결국 공격자의 네임서버에 도착합니다. HTTP 요청을 하나도 보내지 않아도 그렇습니다.

그래서 잠근 샌드박스에서 가짜 키를 넣은 이름으로 DNS 조회를 해봤습니다. 공격자 도메인 대신 example.com 을 썼습니다.

발견: 직접은 막혔지만 VPC DNS가 대신 나갔다

조회 경로 기본 샌드박스 잠근 샌드박스
외부 리졸버로 직접 (8.8.8.8, 223.5.5.5) 응답 옴 타임아웃
VPC DNS(100.100.2.x)로 example.com 응답 옴 실제 IP 응답
VPC DNS로 canary-not-a-real-key-001.example.com 응답 옴 example.com의 외부 권한 네임서버가 응답

잠근 샌드박스는 외부 리졸버에 직접 묻는 것은 막혔습니다. 그런데 VPC DNS에 물으면 VPC DNS가 외부까지 대신 조회해 주었고, 가짜 키가 들어간 이름은 example.com의 권한 네임서버까지 전달됐습니다. 인터넷 출구, NAT, 보안 그룹을 다 잠갔는데도 이 길은 열려 있었습니다.

보안 그룹에 VPC DNS 허용 규칙을 넣은 이유는 게이트웨이 도메인을 찾기 위해서였습니다.

17_t11_dns_exfil

차단: 보안 그룹 규칙 4줄

보안 그룹에서 VPC DNS로 가는 53번 허용 규칙 4개(UDP, TCP 각 2개)를 지웠습니다. 새 리소스도 비용도 들지 않았습니다. 남은 아웃바운드 규칙은 게이트웨이 TCP 443 허용과 전체 거부 두 개뿐입니다.

  • VPC DNS로의 조회는 더 이상 응답이 없었습니다.
  • 외부 웹, 모델 API 직접 호출, 가짜 키 전송은 이제 이름 해석 단계에서부터 막혔습니다.
  • 게이트웨이는 IP를 직접 지정해 부르면 HTTP 200으로 정상 응답했습니다.
curl --resolve <게이트웨이 도메인>:443:<게이트웨이 사설 IP> \
  --cacert ca.pem \
  https://<게이트웨이 도메인>/v1/chat/completions \
  -H "Authorization: Bearer $CONSUMER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "qwen-flash", "messages": [{"role": "user", "content": "hello"}]}'

이 상태에서 인증(401), 인젝션 차단, 쿼터(429)도 다시 확인했고 모두 그대로 동작했습니다.

대신 생기는 것

DNS를 막으면 샌드박스는 어떤 이름도 찾지 못합니다. 게이트웨이는 사설 IP를 지정해 불러야 하고, 에이전트 코드나 SDK가 호스트 이름을 쓴다면 hosts 파일에 등록해 두어야 합니다. 이 IP는 보안 그룹과 샌드박스 두 곳에 들어가므로, 바뀌면 두 곳을 함께 고쳐야 합니다. 인터넷이 필요한 작업도 함께 막힙니다. 에이전트가 pip로 패키지를 설치하거나 웹을 읽어야 한다면, 패키지를 미리 넣은 이미지나 VPC 안의 미러를 따로 준비해야 합니다. 운영 환경에서 도메인 단위로 세밀하게 통제해야 한다면 Cloud Firewall의 DNS Boundary Firewall도 검토해 볼 만합니다. 다만 이번에는 직접 시험하지 않았습니다.

직접 겪은 함정 6가지

  1. 허용 규칙만으로는 아무것도 막히지 않는다. 기본 보안 그룹은 아웃바운드를 전부 허용합니다. 게이트웨이 IP만 허용하는 규칙을 넣고 잠갔다고 생각했지만, 다른 곳으로도 그대로 나갔습니다. 우선순위가 낮은 전체 거부 규칙이 꼭 있어야 합니다.
  2. NAT를 붙이면 샌드박스도 뚫릴 수 있다. NAT를 만들면 시스템 라우트 테이블에 인터넷 경로가 자동으로 추가됩니다. VPC의 vSwitch가 모두 이 테이블을 같이 쓰고 있었기 때문에, 샌드박스를 전용 vSwitch와 전용 라우트 테이블로 옮긴 뒤에야 NAT를 붙일 수 있었습니다.
  3. 설정 하나를 켜면 다른 설정이 꺼질 수 있다. API로 Model API에 가드레일을 켜자 켜 두었던 Consumer 인증이 꺼졌습니다. 업데이트가 일부 수정이 아니라 전체 교체였기 때문입니다. Model API에 HTTPS 도메인을 연결할 때도 같은 일이 한 번 더 일어났습니다. 바꾸려는 설정과 인증을 한 번의 업데이트에 같이 넣고, 설정을 바꾼 뒤에는 키 없는 호출이 여전히 401인지 꼭 확인하는 게 좋습니다.
  4. 규칙 저장은 팝업까지 눌러야 끝난다. 마스킹 규칙을 고치고 OK를 눌러도, 뒤에 뜨는 Confirm to Modify Rule 팝업에서 OK를 누르기 전까지는 게이트웨이가 이전 규칙으로 동작합니다.
  5. 마스킹이 됐다고 복원까지 되는 것은 아니다. deny_openai: false 에 deny_jsonpath 만 지정했을 때는 모델 앞에서 가리는 것까지는 됐지만, restore를 켜 두었는데도 응답이 가려진 값 그대로 왔습니다. deny_openai: true 로 바꾸자 원래 값으로 복원됐습니다. 마스킹을 걸었다면 모델이 받은 값뿐 아니라 사용자가 받는 응답까지 확인하는 게 좋습니다.
  6. 잠긴 샌드박스에서는 Python 네트워크 호출이 엉뚱한 에러를 낸다. Python urllib은 차단된 환경에서 DNS 조회에 15초 넘게 멈췄고, 그동안 샌드박스 프록시가 먼저 502를 돌려주었습니다. 차단 여부를 확인하는 테스트는 curl --max-time 으로 돌리는 게 결과가 깔끔합니다.

이번에 확인하지 못한 것

  • 공격자 쪽 DNS 수신 로그: 가짜 키가 들어간 조회가 외부 권한 네임서버까지 간 것은 샌드박스 쪽 응답으로 확인했습니다. 직접 운영하는 도메인으로 수신 로그를 남기는 실험은 하지 않았습니다.
  • DNS Boundary Firewall: 도메인 단위 통제가 필요한 환경에 맞는 선택지이지만 이번에는 시험하지 않았습니다.

마치며

에이전트 샌드박스를 잠근다는 것은 실행을 격리하는 것에서 끝나지 않습니다. 나가는 길을 하나로 줄이고, 그 하나에서 인증과 검사와 비용 통제를 걸어야 합니다. 이번 구성에서는 이렇게 나눠서 맡겼습니다.

  • 라우트 테이블: 샌드박스 서브넷에는 인터넷 경로를 두지 않는다
  • 보안 그룹: VPC 안에서도 게이트웨이 443 하나로만 갈 수 있게 하고, DNS도 열어두지 않는다
  • AI Gateway: 진짜 키를 보관하고, 인증과 인젝션 차단과 마스킹과 쿼터를 건다
  • MCP Tool Hook: 에이전트가 부르는 도구를 검사한다

Hook은 에이전트가 무엇을 하려는지를 보고, 게이트웨이와 네트워크는 그 결과가 어디로 나가는지를 봅니다. 둘 중 하나만 있으면 다른 쪽으로 새고, 둘을 같이 쓰면 한쪽이 뚫려도 다른 쪽이 막습니다.

참고 자료

0 0 0
Share on

Hosung Kim

10 posts | 1 followers

You may also like