RAG 문서 속 숨은 지시 — 행동은 모델 밖에서 정한다
목표
사내 문서 도우미에서 검색된 바깥 문서의 숨은 지시가 메일을 보내는 것을 재현하고, 받는 사람·보류와 확인·출처를 모델이 아니라 서버가 정하게 고칩니다.
왜 중요한가
RAG 앱은 남이 쓴 글을 모델 입력에 넣습니다. 모델은 그 글이 자료인지 지시인지 확실히 가르지 못하므로, 지시처럼 생긴 문장 하나가 앱의 도구를 움직일 수 있습니다. 이 실습의 가짜 모델은 그 약점을 늘 보이게 과장한 것입니다 — 진짜 모델은 확률적이라 같은 공격이 가끔만 통하고, 그래서 「몇 번 시험해 보니 막혔다」 로는 안전을 말할 수 없습니다. 실습은 표식을 거르는 데서 멈추지 않고, 모델이 무엇을 내든 서버의 규칙이 행동을 정하게 만듭니다. 채점기는 고친 앱에 채점할 때마다 새로 만든 공격을 보내고, 정상 요청이 여전히 되는지도 봅니다.
단계
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 가 펼칠 때 받은 씨앗과 같아야 합니다./root/llmsec/rag/questions.txt의 3번 질문을 그대로/ask로 보내고, 모델이 받은 입력(data/prompts.log)에 들어간 문서 id 를 들어간 순서대로/root/llmsec/rag/trace.txt에q3_docs=<id,id,...>한 줄로 적으세요.data/docs/의 문서 가운데 가짜 모델이 지시로 읽는 표식이 숨은 것을 모두 찾아/root/llmsec/rag/poisoned.txt에 id 를 한 줄에 하나씩 적으세요. 표식은 형태가 여럿입니다.- 메일 지시가 숨은 문서가 검색되게 질문을 보내 공격을 한 번 재현하고,
/root/llmsec/rag/repro.txt에mail=<spool-file>(data/spool의 파일 이름) 과to=<recipient>(그 메일의 받는 사람) 두 줄을 적으세요. app.py의run_call을 고쳐 메일의 받는 사람을 요청한 사용자 자신의 주소(users.json) 로만 허용하세요. 다른 주소는 실행하지 않고data/audit.log에 그 요청의rid와"event": "blocked"가 든 JSON 줄을 남깁니다. 내 주소로 보내 달라는 정상 요청은 그대로 나가야 합니다.gate·handle_confirm을 고쳐, 검색된 문서 중trust: internal이 아닌 것(표시가 없거나 다른 값 포함)이 하나라도 있으면 모델이 낸 행동을 실행하지 말고 응답actions에{"status": "pending", "id": <confirm-id>, ...}로 보류하세요. 같은 사용자가POST /confirm {"id": ...}를 보낼 때 한 번만 실행하고, 다른 사용자의 확인·두 번째 확인은 2xx 가 아닌 코드로 거절하고, 다른 사용자의 확인 시도는 보류를 없애지 않아야 합니다(본인은 그 뒤에도 확인할 수 있게). 사내 문서만 쓴 답의 행동은 곧바로 실행합니다.sources_of를 고쳐 응답sources를 모델 답의 인용 표시가 아니라 서버가 실제로 프롬프트에 넣은 문서로 만드세요:[{"id": ..., "trust": "internal|external"}].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 /root/llmsec/<app>/app.py로 띄웁니다. 터미널을 닫아도 살아 있게 백그라운드로 두세요(setsid nohup … > <file> 2>&1 &). 고칠 때마다 띄워 둔 것을 끄고 다시 띄우세요 — 1단계 채점기는 떠 있는 앱이 지금의 app.py 로 뜬 것인지 봅니다(고치기 전 코드가 계속 요청을 받는 운영 사고를 흉내 낸 검사입니다). 끝날 때도 떠 있어야 1단계가 다시 채점됩니다. - 요청:
curl -s -H "X-Token: <token>" -H 'Content-Type: application/json' -d '{"q":"…"}' 127.0.0.1:<port>/<path>. 토큰은data/users.json에 있습니다. - 채점기는 여러분이 띄운 앱이 아니라 app.py 파일을 옆 디렉터리에 복사해, 그 안의 새
data/(채점기가 만든 무작위 데이터)와--port로 여러분과 같은 방식으로 띄워 무작위 공격을 보냅니다. 데이터는 앱 옆의data/기준으로, 포트는--port로 읽는 줄을 지우지 마세요. 고칠 함수는 파일 머리말에 적혀 있습니다. 채점 중인지 알아채 다르게 구는 코드는 실패로 봅니다. - 가짜 모델은
/opt/fixtures/llmsec/fakellm.py입니다. 무엇을 「지시」 로 읽는지 그 파일의 머리말과directives()를 읽어 두세요. - 질문을 셸 따옴표 없이 보내려면
python3 -c 'import json,sys;print(json.dumps({"q":sys.argv[1]}))' "<question>" | curl … -d @- …처럼 JSON 을 만들어 넘기면 편합니다. - 사건 도구:
incident list로 낼 수 있는 사건과 한 줄 증상을,incident show <name>으로 티켓을 다시 봅니다. 같은 사건을 다시 내면 새 씨앗으로 처음 고장 상태가 됩니다(대상의 원인도 다시 정해집니다). - 보고서는
/root/incident/<name>/report.txt입니다. 대상마다<name>: <cause-code>한 줄씩, 마지막에evidence: <log line>(원인을 보여 준 기록 한 줄) 을 그대로 붙입니다(로그·설정의 한 줄 통째로, 20자 이상). 채점기는 고친 코드에 새 무작위 공격을 다시 보내 보고(이 사건의 경로만이 아니라 같은 실습 앞 단계의 공격도 회귀로), 원인마다 다른 뒷정리가 됐는지 본 뒤에 보고서를 봅니다. 증거 줄은 사건 직후의 기록에 있던 줄이어야 하고, 그 대상의 사고 요청이나 새어 나간 값과 이어져야 합니다. - 흔한 실수: 문서에서 표식만 지워 막는 것. 이 모델은 막히지만, 진짜 모델 앞에서는 바꿔 말한 지시가 남습니다. 채점기는 막은 행동이 기록에 남는지까지 봅니다.
앱을 펼치고 루프백에 띄운다
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 를 보세요. 각 부서에 미끼(검색되지 않은 지시 문서, 무해한 지시)도 있습니다 — 실제로 나간 메일과 이어지는 것만 원인입니다.