자체 호스팅 LLM은 기본적으로 인증 기능이 없습니다. 설정 한 줄이 실행 권한을 결정합니다.
요약
자체 호스팅 LLM 백엔드 설정 시 발생할 수 있는 보안 노출 위험을 경고하며, 설정 파일을 오프라인에서 검사하는 도구인 `exposure_gate.py`를 소개합니다. 바인드 주소나 누락된 인증 키 등 설정 한 줄의 차이가 서비스의 보안 수준을 결정함을 강조합니다.
핵심 포인트
- 자체 호스팅 LLM은 설정에 따라 의도치 않게 외부로 노출될 위험이 큼
- OLLAMA_HOST 설정 변경 등 단 한 줄의 차이로 보안 취약점 발생 가능
- exposure_gate.py는 설정 파일을 오프라인에서 린트하여 보안 항목을 체크함
- 보안 사고는 컴퓨팅 자원 낭비뿐만 아니라 비용(FinOps) 문제로 직결됨
자체 호스팅(Self-hosted) LLM 백엔드는 설정이 모든 인터페이스에 바인딩되는 순간 노출 신호를 보내며, 서비스가 단 하나의 요청에 응답하기도 전에 디스크의 텍스트를 통해 해당 지표를 읽을 수 있습니다. exposure_gate.py는 5가지 체크 항목을 통해 .env, compose 파일, 그리고 LiteLLM config.yaml을 오프라인에서 린트(lint)합니다. 이 포스트의 피스처(fixtures)에서는 단 하나의 필드가 종료 코드(exit code)를 0에서 1로 바꿉니다.
AI 공개 사항: 저는 AI 어시스턴트와 함께
exposure_gate.py를 작성했으며, 게시하기 전에 오프라인에서 직접 실행했습니다. 아래 블록의 모든 숫자는 Python 3.13.5, 표준 라이브러리만 사용하고 네트워크 연결이 없는 실제 로컬 실행 결과에서 복사되었습니다. 저는 종료 코드(0 / 1 / 2)를 확인했으며, 결과가 바이트 단위로 결정론적(deterministic)임을 확인하기 위해 전체 STDOUT을 두 번 해싱했습니다. 외부 주장(Zenity Labs의 허니팟 보고서, AI 액세스 비용 지불 주체에 관한 Dev.to 포스트)은 저의 연구가 아닌 타인의 연구이며, 저는 원본 소스를 링크합니다. 그들의 수치는 각자의 문단에 유지되며, 저의 수치는 오직 여기에 표시된 합성 피스처(synthetic fixtures)에서만 가져온 것입니다.
요약하자면:
- 자체 호스팅 LLM 백엔드의 노출 표면(exposure surface)은 첫 번째 요청이 오기 전에 읽을 수 있는 설정 텍스트에 나타납니다: 바인드 주소(bind address), 공개된 포트(published port), 누락되었거나 자리 표시자(placeholder)로 남겨진
master_key, 그리고 열린 문 뒤에 놓인 프로바이더 키(provider keys) 등입니다. 해당 표면이 실제로 도달 가능한지 여부는 텍스트가 확인할 수 없는 방화벽이나 프록시에 달려 있습니다. - 이 게이트(gate)는
.env,docker-compose.yml, LiteLLMconfig.yaml, systemd 유닛, 그리고mcp.json을 대상으로 오프라인에서 5가지 체크를 수행합니다. 소켓도, 스캐닝도, 키도 사용하지 않습니다. - 중요한 데모: 두 개의 설정 디렉토리가 있으며, 단 한 줄(
OLLAMA_HOST=127.0.0.1이OLLAMA_HOST=0.0.0.0으로 변경됨)을 제외하고는 바이트 단위로 동일합니다. 결과는 exit 0,findings: 0에서 exit 1,findings: 1로 바뀝니다. - 발견된 항목과 동일한 디렉토리에 실제 프로바이더 키가 있는 경우 CRITICAL 단계로 격상됩니다. 이것이 FinOps의 핵심입니다: 열린 문은 컴퓨팅 자원뿐만 아니라 귀하의 과금 측정기(billing meter)에도 도달할 수 있습니다.
- 표준 라이브러리만 사용합니다 (
re,json,sys,pathlib). 오프라인, 키리스(keyless), 읽기 전용, 결정론적(deterministic) STDOUT을 보장합니다.
도구와 모든 피스처(fixtures)는 이 포스트에 포함되어 있습니다.
자체 호스팅 LLM 백엔드는 기본적으로 인증 기능이 없으며, 설정이 접근 권한을 결정합니다
문제의 양상은 다음과 같습니다. Ollama를 로컬에서 실행하면 잘 작동하고, 127.0.0.1에서 데모를 보여주면 모두가 만족합니다. 그러다 다른 사람들이 접근할 수 있는 서버로 옮기게 되면, 그 과정에서 .env 파일이나 compose 파일의 한 줄이 바인드 주소(bind address)를 변경하여 팀원이 API에 접속할 수 있게 만듭니다. 서비스는 여전히 사용자에게 똑같이 잘 작동합니다. 바뀐 유일한 점은 다른 누가 접근할 수 있느냐 하는 것이며, 실행 중인 프로세스에서는 아무런 경고도 발생하지 않습니다.
"나에게는 작동하지만"과 "누구나 접근 가능함" 사이의 이 간극에 위험이 존재하며, 그 징후는 평문(plain text)으로 그대로 드러나 있습니다. 이를 확인하기 위해 스캐너가 필요하지는 않습니다. 바인드 주소와 인증을 결정하는 4~5개의 파일을 읽어야 하며, 매번 동일한 방식으로 읽어야 합니다. 이것이 바로 프로그램이 필요한 이유입니다.
이 관점은 이 블로그에서 새로운 시도입니다. 저는 유출된 키의 폭발 반경 (blast radius)에 대해 글을 쓴 적이 있는데, 이는 탈취된 자격 증명(credential)이 갖는 권한에 관한 것이었습니다. 이번 주제는 그 반대편입니다. 열쇠가 전혀 필요 없는 문, 즉 자격 증명이 아닌 네트워크 표면(network surface)에 관한 것입니다.
허니팟(honeypot) 연구자들이 실제로 발견한 것
저는 1차 연구 자료를 근거로 삼고 있으며, 그들의 주장과 제 도구의 출력값을 분리해서 생각할 가치가 있습니다. 2026년 6월 30일, Zenity Labs는 공격자들이 노출된 AI 백엔드를 하이재킹하는 방식에 대한 허니팟 보고서인 Bring Your Own Agent를 발표했습니다. Ollama에 대한 그들의 설명(제 말이 아닙니다)은 다음과 같습니다. Ollama는 "기본 포트 11434에서 내장된 인증 기능 없이 배포됩니다: 해당 포트에 도달할 수 있는 것이라면 무엇이든 사용할 수 있습니다", 또한 _"기본값은 localhost이지만, OLLAMA_HOST=0.0.0.0을 통해 모든 인터페이스에 바인드되도록 잘못 설정되는 경우가 흔합니다"_라고 명시하고 있습니다.
LiteLLM에 대한 그들의 조사 결과도 동일한 기본 설정을 보여줍니다. 다시 그들의 말을 인용하자면, _"운영자가 마스터 키 (master key)를 설정해야만 액세스를 강제합니다. 설정하지 않고 그대로 두면, 어떤 키 값이라도 허용합니다."_라고 합니다. 그리고 절대 변경되지 않는 플레이스홀더 (placeholder)에 대해서는 다음과 같이 언급합니다: "기본 플레이스홀더 키(sk-1234)를 그대로 사용하는 경우도 매우 흔하며, 공격자들에 의해 테스트되는 모습도 목격되었습니다."
제가 코드를 작성하고 싶게 만든 부분은 바로 이 점입니다: 그들의 완화 조치 (mitigations)는 산문(prose) 형태라는 것입니다. 권장 사항 섹션에는 _"모델 백엔드 (model backends)를 인터넷에 노출하지 마십시오"_와 같은 유사한 지침이 적혀 있는데, 이는 맞는 말이지만 어떤 CI 파이프라인 (CI pipeline)도 실행할 수 없는 내용입니다. 사람이 컨디션이 좋을 때 읽는 체크리스트는, 파이프라인이 매일 실행하는 체크와는 전혀 다른 대상입니다. 저의 5가지 체크 항목은 그들이 제시한 4가지 실패 모드 (failure modes)에 권한 상승 (escalation) 하나를 더해 종료 코드 (exit codes)로 변환한 것입니다. 이것이 바로 여기서 제가 기여하는 전부입니다.
당신이 반박할 수 있도록 명시한 논지
자체 호스팅 LLM 백엔드의 노출 지표 (exposure indicators)는 서비스가 첫 번째 요청을 받기 전, 설정 텍스트 (config text)만으로 결정론적 (deterministically)으로 계산할 수 있습니다. 바인드 주소 (Bind address), 게시된 포트 (published port), 누락되었거나 플레이스홀더 상태인 master_key, 그리고 열린 문 뒤에 놓인 문자 그대로의 제공자 키 (provider key): 각각은 grep으로 찾아낼 수 있는 한 줄의 코드이며, 이 설정 디렉토리에 대한 판결은 텍스트의 고정 함수 (fixed function)입니다.
이 논지를 반박하는 방법은 다음과 같습니다. OLLAMA_HOST=0.0.0.0으로 설정되어 있고 상위 보호 장치가 없음에도 이 게이트 (gate)가 '안전'하다고 통과시키는 설정, 또는 127.0.0.1에 엄격히 바인드되어 있고 환경 변수에서 가져온 master_key를 사용함에도 이 게이트가 '위험'하다고 표시하는 설정을 저에게 보여주십시오. 둘 중 어느 쪽이든 이 도구는 고장 난 것입니다. 비교를 위한 실측값 (ground truth)은 설정 텍스트이며, 결코 네트워크 스캔 (network scan)이 아닙니다. 그리고 그 경계가 이 도구의 정직한 핵심입니다. 이것은 제가 계속해서 구축하고 있는 '실행 전 (pre-execution)' 아이디어의 가장 초기 단계입니다. 즉, 서비스가 수행하는 어떤 동작 이전의 게이트가 아니라, 서비스에 아예 도달할 수 있기 전의 게이트를 만드는 것입니다.
60초 안에 실행하기
키도 필요 없습니다. 네트워크도 필요 없습니다. Python 외에 설치할 것도 없습니다. 파일을 저장하고, 설정 디렉토리를 지정한 뒤, 명령어 하나만 실행하세요. 여기 표준 라이브러리만 사용한 단일 파일로 구성된 전체 도구가 있습니다:
#!/usr/bin/env python3
"""
exposure_gate.py -- 자체 호스팅 AI 백엔드를 위한 오프라인 설정 린트 (config lint)
...
베이스라인: localhost로 연결된 디렉토리
가장 깨끗한 고정 장치(fixture)는 신중한 방식으로 연결된 작은 자체 호스팅 스택입니다. Ollama와 LiteLLM이 localhost 뒤에 위치하며, 포트는 127.0.0.1에 바인딩(bound)되어 있고, master_key와 프로바이더(provider) 키는 파일에 직접 쓰지 않고 환경 변수(environment)에서 가져옵니다. 네 개의 파일이 있습니다:
# clean/.env
# 자체 호스팅 AI 백엔드, 로컬 전용
OLLAMA_HOST=127.0.0.1
...
# clean/docker-compose.yml
services:
ollama:
...
# clean/config.yaml
model_list:
- model_name: gpt-4o
...
동일한 폴더에 있는 mcp.json은 서버를 127.0.0.1로 고정하며, 키 역시 환경 변수에서 읽어옵니다. 해당 디렉토리에 게이트(gate)를 실행합니다:
$ python3 exposure_gate.py fixtures/clean
EXPOSURE-GATE REPORT
files scanned: 4 (env: 1, compose: 1, litellm: 1, systemd: 0, mcp: 1)
...
종료 코드 0 (Exit 0). 네 개의 파일 모두에서 발견된 사항이 없습니다. 여러분이 주목했으면 하는 두 가지가 있습니다. 게이트는 프로바이더 키(provider keys) 자체를 거부하지 않습니다. OPENAI_API_KEY=${OPENAI_API_KEY}는 참조(reference)이므로 리터럴(literal)이 전혀 없는 것으로 간주되며, 요약 줄에도 그렇게 표시됩니다. 또한 os.environ/LITELLM_MASTER_KEY로 설정된 master_key도 같은 이유로 통과됩니다. 게이트는 비밀 정보를 파일 외부에 유지하는 설정을 보상합니다.
필드 하나가 판결을 뒤집습니다
이제 이 포스트가 존재하는 목적이자 데모입니다. 두 번째 디렉토리는 .env 파일의 단 한 줄을 제외하고는 깨끗한(clean) 디렉토리와 바이트 단위로 동일합니다. 동일한 compose, 동일한 설정, 동일한 mcp.json이며, 동일함이 검증되었습니다. 단 하나의 바인드 주소(bind address)만 다릅니다:
$ diff fixtures/clean/.env fixtures/flipped/.env
2c2
< OLLAMA_HOST=127.0.0.1
...
이것이 변경 사항의 전부입니다. 팀원이 박스(box)에 접속할 수 없자 누군가 바인딩(bind)을 확장했고, 서비스는 계속 작동했으며, 변경 사항(diff)은 아무도 두 번 검토하지 않을 단 한 줄뿐이었습니다. 변경된 디렉터리를 게이트(gate)로 지정하세요:
$ python3 exposure_gate.py fixtures/flipped
EXPOSURE-GATE REPORT
files scanned: 4 (env: 1, compose: 1, litellm: 1, systemd: 0, mcp: 1)
...
Exit 1. 동일한 4개의 파일, 동일한 리더(reader), 하나의 필드가 이동했을 뿐인데 결과(verdict)가 반전됩니다. 이것이 제가 신뢰하는 형태인 이유는 진실일 만큼 충분히 작기 때문입니다. 즉, 구성 린트(config lint)는 이와 같은 한 줄의 변경이 프라이빗 서비스와 퍼블릭 서비스의 차이를 만들 때, 그리고 실행 중인 프로세스가 어느 쪽인지에 대한 어떠한 신호도 주지 않을 때 정확히 필요합니다. 만약 당신의 리뷰 프로세스가 매번 그 줄을 잡아낼 수 있다면, 이것은 필요하지 않습니다. 저의 프로세스는 금요일에 그것을 놓칠 것이고, 저는 실제로 금요일 버전(Friday version)을 배포한 적이 있습니다.
문이 당신의 결제 계정으로 이어질 때
위반 사항이 발생한 픽스처(fixture)는 그러한 금요일의 변경 사항들이 몇 개 쌓인 후의 스택(stack)입니다. 바인딩(Bind)은 0.0.0.0으로 확장되었고, 포트(ports)는 localhost 접두사 없이 공개되었으며, LiteLLM 설정에는 master_key가 전혀 없고, 두 개의 프로바이더 키(provider keys)가 .env 파일에 리터럴(literals)로 작성되었습니다. 의도적으로 가짜 키를 넣은 .env 파일은 다음과 같습니다:
# violating/.env
# staging box, "just for testing"
OLLAMA_HOST=0.0.0.0
OLLAMA_PORT=11434
OPENAI_API_KEY=sk-FAKE-openai-3nP9xQ2wodceK
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기