비밀과 개인정보가 새는 자리 — 넣지 않고, 거르고, 가리고, 감지한다
목표
고객 지원 봇에서 시스템 문장의 키, 원문 기록, 고객 구분 없는 검색이라는 세 출구를 찾아 막고, 카나리로 프롬프트 유출을 감지합니다.
왜 중요한가
모델 입력에 들어간 것은 언젠가 답으로 나올 수 있고, 입력을 적는 기록은 그 내용을 더 많은 곳으로 복제합니다. 「모델에게 말하지 말라고 적어 두기」 는 경계가 아닙니다 — 진짜 모델은 확률적이라 대부분 지키다가 어느 날 지키지 않습니다. 이 실습은 비밀을 아예 넣지 않고(도구 층), 남의 것은 넣기 전에 거르고(검색), 기록은 가리고, 그래도 새면 카나리로 알아채는 순서로 고칩니다. 채점기는 채점할 때마다 새 키·카나리·전화번호를 만든 임시 데이터로 앱을 띄워, 그 값이 응답·기록 어디에 남는지 봅니다.
단계
python3 /opt/fixtures/llmsec/kit.py support로 재료를 펼친 뒤/root/llmsec/support/app.py를 127.0.0.1:8102 에 띄워 두세요(0.0.0.0 금지, 터미널을 닫아도 살아 있게).curl 127.0.0.1:8102/health의 seed 가 펼칠 때 받은 씨앗과 같아야 합니다.- 어제 기록
/root/llmsec/support/logs/app-2026-11-04.log에 원문으로 남은 것을 세어/root/llmsec/support/exposure.txt에tokens=<count>(기록에 나온 서로 다른 사용자 토큰 수),phones=<count>(서로 다른 전화번호 수),key_lines=<count>(협력사 키API 키: pk-…가 든 줄 수) 세 줄을 적으세요. build_system·run_tool을 고쳐 협력사 키를 시스템 문장에서 빼고, 주문 조회 도구가 실행될 때 서버가config.json에서 직접 읽게 하세요. 시스템 문장을 꺼내 달라는 요청에도 키가 응답·기록 어디에도 나오지 않고, 주문 조회는 그대로 돼야 합니다.log_request를 고쳐data/app.log에 요청마다 한 줄(rid·사용자 이름)은 남기되 X-Token 값·전화번호·이메일·협력사 키가 원문으로 남지 않게 하세요. 전화번호는 끝 네 자리만 남깁니다(***-****-1234).build_system·check_output을 고쳐config.json의canary값을 시스템 문장에 넣고, 응답에 그 값이 보이면 답을 통째로 막고data/audit.log에 그 요청의rid와"event": "canary"가 든 JSON 줄을 남기세요. 정상 답은 그대로 나가야 합니다.find_tickets를 고쳐 모델에게 넣는 티켓을 요청한 고객의 것으로만 거르세요. 남의 티켓 낱말로 물어도 남의 정보가 답에 섞이지 않고, 자기 티켓으로는 답해야 합니다.incident ls-secret-leak을 실행하면 사건이 납니다. 고객 세 명의 전화번호가 회사 밖에서 발견됐습니다(/srv/ls-support/found.txt). 고객마다 경로를 찾아 원천을 정리하고 코드를 실습 3~6단계처럼 고친 뒤/root/incident/ls-secret-leak/report.txt에<customer>: <code>세 줄과evidence: <log line>을 적으세요. 코드는cross-tenant(다른 고객의 티켓이 답에 섞임),log-bundle(협력사에 보낸 진단 묶음),misfiled-ticket(주인이 잘못 적힌 티켓),prompt-extract(시스템 문장 추출) 가운데 하나입니다.
참고
- 앱은
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()를 읽어 두세요. - 협력사 주문 API 는
/opt/fixtures/llmsec/partner.py(가짜)입니다. 키가config.json의 값과 같아야 답합니다 — 고칠 파일이 아닙니다. - 사건 도구:
incident list로 낼 수 있는 사건과 한 줄 증상을,incident show <name>으로 티켓을 다시 봅니다. 같은 사건을 다시 내면 새 씨앗으로 처음 고장 상태가 됩니다(대상의 원인도 다시 정해집니다). - 보고서는
/root/incident/<name>/report.txt입니다. 대상마다<name>: <cause-code>한 줄씩, 마지막에evidence: <log line>(원인을 보여 준 기록 한 줄) 을 그대로 붙입니다(로그·설정의 한 줄 통째로, 20자 이상). 채점기는 고친 코드에 새 무작위 공격을 다시 보내 보고(이 사건의 경로만이 아니라 같은 실습 앞 단계의 공격도 회귀로), 원인마다 다른 뒷정리가 됐는지 본 뒤에 보고서를 봅니다. 증거 줄은 사건 직후의 기록에 있던 줄이어야 하고, 그 대상의 사고 요청이나 새어 나간 값과 이어져야 합니다. - 흔한 실수: 기록을 아예 끄는 것. 장애 때 조사할 수 없게 되고, 채점기는 요청 id 가 든 줄이 남는지 봅니다.
앱을 펼치고 루프백에 띄운다
python3 /opt/fixtures/llmsec/kit.py support 로 재료를 펼친 뒤 /root/llmsec/support/app.py 를 127.0.0.1:8102 에 띄워 두세요(0.0.0.0 금지, 터미널을 닫아도 살아 있게). curl 127.0.0.1:8102/health 의 seed 가 펼칠 때 받은 씨앗과 같아야 합니다.
방향: 실습 앱은 바깥에 열 이유가 없습니다 — 듣는 주소가 곧 노출 범위입니다. 도구: ss -ltnp, curl, setsid·nohup. 구체: 펼치기 명령은 위 그대로입니다. 띄운 뒤 ss -ltn 에서 8102 번이 127.0.0.1 로 보이는지, /health 의 seed 가 /root/llmsec/support/data/seed.txt 와 같은지 보세요.
어제 기록에 원문으로 남은 것을 센다
어제 기록 /root/llmsec/support/logs/app-2026-11-04.log 에 원문으로 남은 것을 세어 /root/llmsec/support/exposure.txt 에 tokens=<count>(기록에 나온 서로 다른 사용자 토큰 수), phones=<count>(서로 다른 전화번호 수), key_lines=<count>(협력사 키 API 키: pk-… 가 든 줄 수) 세 줄을 적으세요.
방향: 원문 기록은 프롬프트에 들어간 모든 것의 복사본입니다. 무엇이 몇 개 복제됐는지 세는 것이 피해 범위를 정하는 첫걸음입니다. 도구: grep -o, sort -u, wc -l, jq, data/users.json. 구체: 토큰은 users.json 의 값이 기록에 나오는지로, 전화번호는 서로 다른 값의 수로, 키는 줄 수로 셉니다 — 셋이 세는 단위가 다릅니다.
키를 시스템 문장에서 도구 층으로
build_system·run_tool 을 고쳐 협력사 키를 시스템 문장에서 빼고, 주문 조회 도구가 실행될 때 서버가 config.json 에서 직접 읽게 하세요. 시스템 문장을 꺼내 달라는 요청에도 키가 응답·기록 어디에도 나오지 않고, 주문 조회는 그대로 돼야 합니다.
방향: 모델은 키가 필요하지 않습니다. 키가 필요한 것은 도구를 실행하는 서버 코드입니다. 도구: build_system, run_tool, jload("config.json"). 구체: 시스템 문장에서 키 줄을 없애고, run_tool 이 시스템 문장을 뒤지는 대신 설정을 읽게 바꾸세요. 채점기는 키를 새로 만들어 응답과 config.json 밖의 모든 파일을 봅니다.
기록은 남기되 가린다
log_request 를 고쳐 data/app.log 에 요청마다 한 줄(rid·사용자 이름)은 남기되 X-Token 값·전화번호·이메일·협력사 키가 원문으로 남지 않게 하세요. 전화번호는 끝 네 자리만 남깁니다(***-****-1234).
방향: 끄면 조사할 수 없고, 다 적으면 기록이 유출 경로가 됩니다. 무엇을 남길지 고르는 일입니다. 도구: re.sub, json.dumps. 구체: 머리글은 통째로 남기지 말고 신원은 이름으로, 질문·문맥·답의 번호와 이메일은 정규식으로 가리세요. 시스템 문장은 기록할 이유가 없습니다.
카나리로 프롬프트 유출을 감지한다
build_system·check_output 을 고쳐 config.json 의 canary 값을 시스템 문장에 넣고, 응답에 그 값이 보이면 답을 통째로 막고 data/audit.log 에 그 요청의 rid 와 "event": "canary" 가 든 JSON 줄을 남기세요. 정상 답은 그대로 나가야 합니다.
방향: 카나리는 비밀 대신 넣어 두는 표식입니다. 그것이 출력에 보이면 시스템 문장이 샌 것이니, 카나리만 지우지 말고 답을 통째로 막습니다. 도구: build_system, check_output, audit(). 구체: 카나리는 매번 설정에서 읽고, 비교 전에 공백·대소문자를 고르게 하세요. 진짜 모델이라면 바꿔 말해 빠져나갈 수 있다는 한계도 기억해 둡니다.
남의 티켓은 넣기 전에 거른다
find_tickets 를 고쳐 모델에게 넣는 티켓을 요청한 고객의 것으로만 거르세요. 남의 티켓 낱말로 물어도 남의 정보가 답에 섞이지 않고, 자기 티켓으로는 답해야 합니다.
방향: 입력에 들어간 것은 말할 수 있는 것입니다. 권한은 검색 단계에서 겁니다. 도구: find_tickets 의 user, tickets.json 의 owner. 구체: 점수를 매기기 전에 주인이 다른 티켓을 빼세요.
사건 — 세 고객의 번호가 바깥에서 발견됐다
incident ls-secret-leak 을 실행하면 사건이 납니다. 고객 세 명의 전화번호가 회사 밖에서 발견됐습니다 — 발견된 조각은 /srv/ls-support/found.txt, 봇은 /srv/ls-support/app.py, 데이터·기록은 /srv/ls-support/data/, 지난주 협력사에 보낸 진단 묶음은 /srv/ls-support/support-bundle.log 입니다. 고객마다 번호가 나간 경로를 찾아 원천을 정리하세요 — 잘못 적힌 티켓 주인은 번호의 주인으로 바로잡고, 시스템 문장의 번호는 빼고, 진단 묶음은 줄을 남긴 채 번호만 가립니다. 코드 /srv/ls-support/app.py 는 실습 3~6단계에서 고친 결함을 모두 막아야 합니다(키는 도구 층, 기록은 가려서, 카나리, 검색은 고객별로 — 채점기가 그 공격을 다시 보냅니다). /root/incident/ls-secret-leak/report.txt 에 <customer>: <code> 세 줄과 evidence: <log line> 을 적습니다. 코드는 고객마다 다음 중 하나입니다: cross-tenant(다른 고객의 티켓이 답에 섞임), log-bundle(협력사에 보낸 진단 묶음), misfiled-ticket(주인이 잘못 적힌 티켓), prompt-extract(시스템 문장 추출).
방향: 증상은 셋 다 「번호가 바깥에 있다」 입니다. 발견된 조각의 모양 그대로가 어느 기록에서 나올 수 있었는지가 경로입니다. 도구: grep -F, jq, tickets.json 의 owner, users.json 의 phone. 구체: 조각을 그대로 grep 해 app.log 의 답(answer)인지, 진단 묶음의 질문(q)인지 보세요. 답이라면 그 요청을 한 사람과 문맥에 들어간 티켓의 주인을, 번호의 실제 주인(users.json)과 견주면 다른 고객의 티켓이 섞인 것과 티켓 주인이 잘못 적힌 것이 갈립니다.