LLM 에이전트는 모델이 고른 도구를 대신 실행해 주는 프로그램입니다. 이 글에서는 파일을 읽고 쓰는 도구 두 벌을 만듭니다. 한 벌은 모델이 넘긴 경로를 그대로 열고, 다른 한 벌은 허용 폴더 안인지 확인한 뒤에만 엽니다.
같은 요청을 두 벌에 보내 기밀 파일이 읽히는지, 허용되지 않은 위치에 파일이 써지는지 비교합니다. OWASP Top 10 for LLM Applications 2025에서 이 문제는 LLM06:2025 Excessive Agency입니다. 외부 문서나 웹 페이지에 숨은 지시를 모델이 따르면 기밀 파일 경로가 도구 인자로 전달될 수 있습니다. 공격 기법은 직접 만든 실습 폴더에서만 실행합니다.
준비물
| 항목 | 내용 |
|---|---|
| Python | 3.12 (이 글의 출력은 3.12.10에서 확인) |
| 외부 패키지 | 없음 (표준 라이브러리만 사용) |
| LLM 키 | 필요 없음 |
| 예상 소요 시간 | 약 15분 |
모델이 아래 경로를 인자로 넘겼다고 가정하고 도구 함수를 직접 호출합니다. 위험은 도구가 받은 경로를 어떻게 다루느냐에서 생기기 때문입니다.
Excessive Agency가 무엇인지
OWASP는 원인을 세 가지로 나눕니다. 에이전트에 필요 이상의 기능을 주는 경우, 필요 이상의 권한을 주는 경우, 영향이 큰 동작을 사람이 확인하지 않고 실행하게 두는 경우입니다.
이 글의 취약 도구는 기능, 권한, 자율성 중 주로 권한 문제를 보여 줍니다. 도구가 파일 시스템 전체를 열 수 있으면 모델이 속아서든 실수로든 읽으면 안 되는 파일에 닿습니다.
각 변경이 줄이는 원인은 다음과 같습니다.
- 허용 폴더 검사: 도구가 닿는 범위를 제한하므로 권한 원인을 줄입니다.
- 읽기와 쓰기 두 가지 동작만 제공: 삭제 같은 동작을 두지 않으므로 기능 원인을 줄입니다.
- 쓰기 전 사람 승인: 이 글의 코드에는 없고, 마지막 절에서 직접 추가해 봅니다. 자율성 원인을 줄입니다.
실습 폴더 만들기
빈 폴더를 만들고 읽어도 되는 파일과 기밀 파일을 하나씩 둡니다. 기밀 파일의 내용은 가짜 값입니다.
아래 명령은 Git Bash나 리눅스, 맥 터미널 기준입니다. PowerShell에서는 New-Item 과 Set-Content -Encoding utf8 로 같은 폴더와 파일을 만듭니다.
mkdir excessive-agency && cd excessive-agency
mkdir -p workspace/allowed workspace/forbidden workspace/output
printf '에이전트가 읽어도 되는 파일입니다.\n' > workspace/allowed/readme.txt
printf 'API_KEY=dummy-not-a-real-key\n' > workspace/forbidden/secret.txt
기대 출력은 없습니다. 오류 없이 끝나면 됩니다.
도구 두 벌 작성하기
아래 내용을 agent.py로 저장합니다. vuln_ 로 시작하는 함수는 경로를 검증하지 않습니다. secure_ 로 시작하는 함수는 os.path.realpath 로 경로를 정규화한 뒤 허용 폴더 안인지 확인합니다.
import os
import sys
sys.stdout.reconfigure(encoding="utf-8")
ALLOWED_READ_DIRS = [os.path.realpath("workspace/allowed")]
ALLOWED_WRITE_DIRS = [os.path.realpath("workspace/output")]
MAX_FILE_SIZE = 512 * 1024
def vuln_read(path):
try:
with open(path, encoding="utf-8") as f:
return f.read().strip()
except OSError as e:
return f"오류: {e}"
def vuln_write(path, content):
with open(path, "w", encoding="utf-8") as f:
f.write(content)
return f"저장 완료: {path}"
def check_path(path, allowed_dirs, action):
real = os.path.realpath(path)
if not any(real == d or real.startswith(d + os.sep) for d in allowed_dirs):
raise PermissionError(f"{action} 허용되지 않은 경로: {path}")
return real
def secure_read(path):
try:
real = check_path(path, ALLOWED_READ_DIRS, "읽기")
if os.path.getsize(real) > MAX_FILE_SIZE:
return "파일이 너무 큽니다"
with open(real, encoding="utf-8") as f:
return f.read().strip()
except PermissionError as e:
return f"[차단] {e}"
except OSError as e:
return f"오류: {e}"
def secure_write(path, content):
try:
real = check_path(path, ALLOWED_WRITE_DIRS, "쓰기")
with open(real, "w", encoding="utf-8") as f:
f.write(content)
return f"저장 완료: {os.path.relpath(real)}"
except PermissionError as e:
return f"[차단] {e}"
except OSError as e:
return f"오류: {e}"
허용 폴더 비교에서 d + os.sep 를 붙이는 이유가 있습니다. 붙이지 않으면 workspace/allowed-old 처럼 이름이 같은 글자로 시작하는 다른 폴더가 통과합니다.
같은 요청을 두 벌에 보내기
agent.py 맨 아래에 요청 목록과 실행부를 덧붙입니다. 두 번째와 세 번째 읽기, 마지막 쓰기가 공격 요청입니다. 세 번째 읽기는 허용 폴더에서 시작해 .. 로 빠져나가는 경로를 씁니다.
CASES = [
("read", "workspace/allowed/readme.txt", None),
("read", "workspace/forbidden/secret.txt", None),
("read", "workspace/allowed/../forbidden/secret.txt", None),
("write", "workspace/output/result.txt", "정상 출력"),
("write", "workspace/forbidden/evil.txt", "덮어쓰기 시도"),
]
for op, path, content in CASES:
print(f"[{op}] {path}")
if op == "read":
print(" 취약:", vuln_read(path))
print(" 보안:", secure_read(path))
else:
print(" 취약:", vuln_write(path, content))
print(" 보안:", secure_write(path, content))
python agent.py
기대 출력은 아래와 같습니다. 윈도에서는 네 번째 보안: 줄의 경로가 workspace\output\result.txt 처럼 역슬래시로 나옵니다.
[read] workspace/allowed/readme.txt
취약: 에이전트가 읽어도 되는 파일입니다.
보안: 에이전트가 읽어도 되는 파일입니다.
[read] workspace/forbidden/secret.txt
취약: API_KEY=dummy-not-a-real-key
보안: [차단] 읽기 허용되지 않은 경로: workspace/forbidden/secret.txt
[read] workspace/allowed/../forbidden/secret.txt
취약: API_KEY=dummy-not-a-real-key
보안: [차단] 읽기 허용되지 않은 경로: workspace/allowed/../forbidden/secret.txt
[write] workspace/output/result.txt
취약: 저장 완료: workspace/output/result.txt
보안: 저장 완료: workspace/output/result.txt
[write] workspace/forbidden/evil.txt
취약: 저장 완료: workspace/forbidden/evil.txt
보안: [차단] 쓰기 허용되지 않은 경로: workspace/forbidden/evil.txt
정상 요청은 두 벌 모두 같은 결과를 냅니다. 차이는 공격 요청에서만 납니다.
쓰기가 실제로 일어났는지 확인하기
취약 도구가 forbidden 폴더에 파일을 만들었는지 목록으로 봅니다.
ls workspace/forbidden
evil.txt
secret.txt
evil.txt 는 취약 도구가 만든 파일입니다. 보안 도구만 실행했다면 이 파일은 생기지 않습니다. [차단] 메시지와 파일 유무는 따로 확인해야 하므로, 쓰기 방어는 이렇게 결과 파일로도 검증합니다.
자주 막히는 곳
UnicodeEncodeError: 'cp949' codec can't encode character: 코드 첫머리의 sys.stdout.reconfigure(encoding="utf-8") 가 있으면 대부분 나지 않습니다. 이 줄을 빼먹었거나, 출력을 파일로 리디렉션하는 등 stdout 이 바뀐 환경에서 나면 python -X utf8 agent.py 로 실행합니다.
오류: [Errno 2] No such file or directory: 'workspace/allowed/readme.txt': agent.py 를 실습 폴더 밖에서 실행했을 때 나옵니다. excessive-agency 폴더 안에서 실행합니다. 허용 폴더도 실행 위치 기준으로 계산되므로 위치가 다르면 보안 도구도 엉뚱한 곳을 기준으로 삼습니다.
[차단] 이 정상 요청에서도 나옵니다: 허용 폴더 목록을 ALLOWED_READ_DIRS 에 넣을 때 realpath 를 빼먹은 경우입니다. 비교 대상 양쪽을 모두 realpath 로 맞춥니다.
이어서 해 볼 것
realpath 없이 원문 문자열이 workspace/allowed/ 로 시작하는지만 보는 검증을 만들어 봅니다. workspace/allowed/../forbidden/secret.txt 를 보내면 통과하는지 확인합니다.
심볼릭 링크로도 해 봅니다. workspace/allowed 안에 forbidden 폴더를 가리키는 링크를 만들고 secure_read 로 읽어 봅니다. 윈도에서는 링크 생성에 권한이 필요할 수 있습니다.
OWASP가 말한 나머지 두 원인도 코드로 옮겨 봅니다. 도구 목록에서 delete 같은 동작을 아예 빼는 것은 기능 제한이고, 쓰기 직전에 사람의 승인을 받는 확인 단계를 넣는 것은 자율성 제한입니다.
확인 범위
위 agent.py 는 윈도의 Python 3.12.10에서 직접 실행해 출력을 확인했습니다.
리눅스, 맥, Docker 컨테이너, 3.12 이외 버전, 심볼릭 링크 실험, 실제 LLM이 도구를 호출하는 구성은 확인하지 않았고, 경로 구분자 차이만 표기했습니다.
OWASP 항목 번호와 이름은 https://genai.owasp.org/llmrisk/llm062025-excessive-agency/ 의 설명과 대조했습니다.