TT Lab
블로그 시작하기
배우기 러닝패스 코스

LLM 앱 보안 — 주입에서 출력 처리까지

RAG 문서 속 숨은 지시 — 행동은 모델 밖에서 정한다

TT Lab 에서 이어서 보기

목표

사내 문서 도우미에서 검색된 바깥 문서의 숨은 지시가 메일을 보내는 것을 재현하고, 받는 사람·보류와 확인·출처를 모델이 아니라 서버가 정하게 고칩니다.

왜 중요한가

RAG 앱은 남이 쓴 글을 모델 입력에 넣습니다. 모델은 그 글이 자료인지 지시인지 확실히 가르지 못하므로, 지시처럼 생긴 문장 하나가 앱의 도구를 움직일 수 있습니다. 이 실습의 가짜 모델은 그 약점을 늘 보이게 과장한 것입니다 — 진짜 모델은 확률적이라 같은 공격이 가끔만 통하고, 그래서 「몇 번 시험해 보니 막혔다」 로는 안전을 말할 수 없습니다. 실습은 표식을 거르는 데서 멈추지 않고, 모델이 무엇을 내든 서버의 규칙이 행동을 정하게 만듭니다. 채점기는 고친 앱에 채점할 때마다 새로 만든 공격을 보내고, 정상 요청이 여전히 되는지도 봅니다.

단계

  1. python3 /opt/fixtures/llmsec/kit.py rag 로 재료를 펼친 뒤 /root/llmsec/rag/app.py 를 127.0.0.1:8101 에 띄워 두세요(0.0.0.0 금지, 터미널을 닫아도 살아 있게). curl 127.0.0.1:8101/health 의 seed 가 펼칠 때 받은 씨앗과 같아야 합니다.
  2. /root/llmsec/rag/questions.txt 의 3번 질문을 그대로 /ask 로 보내고, 모델이 받은 입력(data/prompts.log)에 들어간 문서 id 를 들어간 순서대로 /root/llmsec/rag/trace.txt 에 q3_docs=<id,id,...> 한 줄로 적으세요.
  3. data/docs/ 의 문서 가운데 가짜 모델이 지시로 읽는 표식이 숨은 것을 모두 찾아 /root/llmsec/rag/poisoned.txt 에 id 를 한 줄에 하나씩 적으세요. 표식은 형태가 여럿입니다.
  4. 메일 지시가 숨은 문서가 검색되게 질문을 보내 공격을 한 번 재현하고, /root/llmsec/rag/repro.txt 에 mail=<spool-file>(data/spool 의 파일 이름) 과 to=<recipient>(그 메일의 받는 사람) 두 줄을 적으세요.
  5. app.py 의 run_call 을 고쳐 메일의 받는 사람을 요청한 사용자 자신의 주소(users.json) 로만 허용하세요. 다른 주소는 실행하지 않고 data/audit.log 에 그 요청의 rid 와 "event": "blocked" 가 든 JSON 줄을 남깁니다. 내 주소로 보내 달라는 정상 요청은 그대로 나가야 합니다.
  6. gate·handle_confirm 을 고쳐, 검색된 문서 중 trust: internal 이 아닌 것(표시가 없거나 다른 값 포함)이 하나라도 있으면 모델이 낸 행동을 실행하지 말고 응답 actions 에 {"status": "pending", "id": <confirm-id>, ...} 로 보류하세요. 같은 사용자가 POST /confirm {"id": ...} 를 보낼 때 한 번만 실행하고, 다른 사용자의 확인·두 번째 확인은 2xx 가 아닌 코드로 거절하고, 다른 사용자의 확인 시도는 보류를 없애지 않아야 합니다(본인은 그 뒤에도 확인할 수 있게). 사내 문서만 쓴 답의 행동은 곧바로 실행합니다.
  7. sources_of 를 고쳐 응답 sources 를 모델 답의 인용 표시가 아니라 서버가 실제로 프롬프트에 넣은 문서로 만드세요: [{"id": ..., "trust": "internal|external"}].
  8. incident ls-rag-exfil 을 실행하면 사건이 납니다. 세 부서(faq·hr·sales)의 도우미에서 어젯밤 회사 밖 주소로 메일이 나갔습니다. 코드 /srv/ls-rag/bot.py(세 부서 공용)를 고쳐 어떤 경로로 와도 회사 밖(@corp.example 이 아닌) 주소로는 메일이 나가지 않게 하고, 부서마다 원인의 원천을 정리한 뒤 /root/incident/ls-rag-exfil/report.txt 에 faq: <code>, hr: <code>, sales: <code> 와 evidence: <log line> 을 적으세요. 코드는 config-cc(설정의 참조 주소), memory-note(사용자 메모 속 지시), poisoned-doc(검색 문서 속 지시), user-prompt(사용자 질문 속 지시) 가운데 하나입니다.

참고

앱을 펼치고 루프백에 띄운다

python3 /opt/fixtures/llmsec/kit.py rag 로 재료를 펼친 뒤 /root/llmsec/rag/app.py 를 127.0.0.1:8101 에 띄워 두세요(0.0.0.0 금지, 터미널을 닫아도 살아 있게). curl 127.0.0.1:8101/health 의 seed 가 펼칠 때 받은 씨앗과 같아야 합니다.

방향: 실습 앱은 바깥에 열 이유가 없습니다 — 듣는 주소가 곧 노출 범위입니다. 도구: ss -ltnp, curl, setsid·nohup. 구체: 펼치기 명령은 위 그대로입니다. 띄운 뒤 ss -ltn 에서 8101 번이 127.0.0.1 로 보이는지, /health 의 seed 가 /root/llmsec/rag/data/seed.txt 와 같은지 보세요.

모델이 실제로 받은 입력을 읽는다

/root/llmsec/rag/questions.txt 의 3번 질문을 그대로 /ask 로 보내고, 모델이 받은 입력(data/prompts.log)에 들어간 문서 id 를 들어간 순서대로 /root/llmsec/rag/trace.txt 에 q3_docs=<id,id,...> 한 줄로 적으세요.

방향: RAG 에서 모델 입력의 대부분은 사용자가 쓴 글이 아니라 검색된 남의 글입니다. 무엇이 들어갔는지는 짐작하지 말고 기록에서 읽습니다. 도구: curl, grep, awk. 구체: 응답의 rid 를 잡아 prompts.log 에서 그 rid 의 part=context 줄을 보고, 줄마다 붙은 src= 를 처음 나온 순서대로 모으세요.

형태가 다른 숨은 지시를 모두 찾는다

data/docs/ 의 문서 가운데 가짜 모델이 지시로 읽는 표식이 숨은 것을 모두 찾아 /root/llmsec/rag/poisoned.txt 에 id 를 한 줄에 하나씩 적으세요. 표식은 형태가 여럿입니다.

방향: 모델은 필터보다 너그럽게 읽습니다. 한 가지 모양으로 grep 하면 모양이 다른 것을 놓치고, 비슷해 보이는 미끼는 지시가 아닙니다. 도구: fakellm.py 의 directives(), unicodedata, base64, grep. 구체: 가짜 모델이 입력을 어떻게 정규화하고 무엇을 풀어 읽는지 그 함수를 먼저 읽은 뒤, 같은 규칙으로 문서마다 판정하세요.

공격을 한 번 재현한다

메일 지시가 숨은 문서가 검색되게 질문을 보내 공격을 한 번 재현하고, /root/llmsec/rag/repro.txt 에 mail=<spool-file>(data/spool 의 파일 이름) 과 to=<recipient>(그 메일의 받는 사람) 두 줄을 적으세요.

방향: 공격자는 우리 사용자에게 말을 걸지 않습니다. 사용자의 평범한 질문이 그 문서를 끌어오기만 하면 됩니다. 도구: 검색 규칙(retrieve), curl, ls data/spool. 구체: 메일 지시가 든 문서 하나를 골라 그 문서 본문의 낱말로 질문을 만들면 그 문서가 위로 올라옵니다. 응답 actions 와 스풀 파일의 to 를 맞춰 보세요.

받는 사람은 서버가 정한다

app.py 의 run_call 을 고쳐 메일의 받는 사람을 요청한 사용자 자신의 주소(users.json) 로만 허용하세요. 다른 주소는 실행하지 않고 data/audit.log 에 그 요청의 rid 와 "event": "blocked" 가 든 JSON 줄을 남깁니다. 내 주소로 보내 달라는 정상 요청은 그대로 나가야 합니다.

방향: 모델이 낸 인자(to)는 공격자가 쓴 글일 수 있습니다. 그 값으로 무엇을 할지는 요청한 사람의 신원으로 정합니다. 도구: run_call 의 user 인자, audit(). 구체: 받는 사람이 user 의 email 과 다르면 보내지 않고 막은 사실을 기록하세요. 채점기는 숨은 지시(형태 무작위)와, 사용자가 직접 바깥 주소를 댄 요청, 정상 요청을 함께 보냅니다.

믿을 수 없는 글이 섞이면 사람이 확인한다

gate·handle_confirm 을 고쳐, 검색된 문서 중 trust: internal 이 아닌 것(표시가 없거나 다른 값 포함)이 하나라도 있으면 모델이 낸 행동을 실행하지 말고 응답 actions 에 {"status": "pending", "id": <confirm-id>, ...} 로 보류하세요. 같은 사용자가 POST /confirm {"id": ...} 를 보낼 때 한 번만 실행하고, 다른 사용자의 확인·두 번째 확인은 2xx 가 아닌 코드로 거절하고, 다른 사용자의 확인 시도는 보류를 없애지 않아야 합니다(본인은 그 뒤에도 확인할 수 있게). 사내 문서만 쓴 답의 행동은 곧바로 실행합니다.

방향: 받는 사람을 본인으로 묶어도, 바깥 문서의 지시가 본인에게 엉뚱한 메일(가짜 결제 링크 같은)을 보내게 할 수 있습니다. 위험한 순간에만 사람을 끼웁니다. 도구: PENDING 사전, secrets.token_urlsafe, 확인 뒤에도 run_call 을 거치게. 구체: 확인 id 는 누가 만들었는지와 함께 보관하고, 쓰면 지웁니다. 남의 것과 없는 것은 같은 응답으로 거절하면 id 가 있는지조차 새지 않습니다.

출처는 서버가 붙인다

sources_of 를 고쳐 응답 sources 를 모델 답의 인용 표시가 아니라 서버가 실제로 프롬프트에 넣은 문서로 만드세요: [{"id": ..., "trust": "internal|external"}].

방향: 「[kb-07] 에 따르면」 이라는 말도 모델의 출력입니다. 바깥 문서가 사내 규정을 사칭하면 사람은 그 인용을 믿습니다. 도구: sources_of 가 받는 docs. 구체: 답 텍스트를 해석하지 말고 검색 결과에서 id 와 trust 를 그대로 옮기세요. 표시가 없는 문서는 바깥 것으로 다루는 편이 안전합니다.

사건 — 세 부서에서 회사 밖으로 메일이 나갔다

incident ls-rag-exfil 을 실행하면 사건이 납니다. 세 부서(faq·hr·sales)의 도우미에서 어젯밤 회사 밖 주소로 메일이 나갔습니다(스풀 /srv/ls-rag/bots/<dept>/spool/, 기록 prompts.log·audit.log). 코드 /srv/ls-rag/bot.py(세 부서 공용)를 고쳐 어떤 경로로 와도 회사 밖(@corp.example 이 아닌) 주소로는 메일이 나가지 않게 하고(받는 사람과 참조 모두, 사내 주소로 가는 정상 메일은 그대로), 부서마다 원인의 원천을 정리하세요 — 오염 문서는 검색 대상에서, 오염 메모는 메모에서 빼고, 지시를 넣은 계정은 토큰이 통하지 않게, 설정의 바깥 참조는 지웁니다(원인과 무관한 문서·메모는 남깁니다). /root/incident/ls-rag-exfil/report.txt 에 faq: <code>, hr: <code>, sales: <code> 와 evidence: <log line> 을 적습니다. 코드는 부서마다 다음 중 하나입니다: config-cc(설정의 참조 주소), memory-note(사용자 메모 속 지시), poisoned-doc(검색 문서 속 지시), user-prompt(사용자 질문 속 지시).

방향: 증상은 셋 다 같지만 지시가 들어온 자리가 다릅니다. 메일 한 통에서 거꾸로 올라가 그 요청의 입력을 조각별로 봅니다. 도구: grep, jq, spool 의 rid·cc, prompts.log 의 part=·src=. 구체: 회사 밖 주소가 든 스풀 파일의 rid 로 prompts.log 를 열어 지시 표식이 어느 part(memory·context·user)에 있었는지 보고, 표식이 없는데 나갔다면 메일의 cc 가 어디서 붙었는지 config 를 보세요. 각 부서에 미끼(검색되지 않은 지시 문서, 무해한 지시)도 있습니다 — 실제로 나간 메일과 이어지는 것만 원인입니다.