모델이 고른 도구를 누구 권한으로 실행하나 — 역할·허용 목록·경로·확인
목표
모델이 고른 도구를 서비스 권한으로 그대로 실행하는 파일 비서를, 요청한 사용자의 역할·도구 허용 목록·경로 검증·사람의 확인으로 묶습니다.
왜 중요한가
에이전트의 도구는 앱 서버의 권한으로 실행되고, 모델은 사용자의 말과 문서 속 말을 가리지 않습니다. 그래서 viewer 의 부탁이나 공유 문서 속 문장 하나가 지우기를 실행할 수 있습니다(혼동된 대리인). 이 실습의 가짜 모델은 그 약점을 늘 보이게 만든 것이고, 진짜 모델은 「가끔」 그럴 뿐입니다 — 그 가끔을 시험으로 다 찾을 수 없으니 권한은 모델 밖에서 겁니다. 채점기는 채점할 때마다 새 사용자·파일·지시로 앱을 띄워, 파일이 실제로 지워지거나 써지거나 읽히는지 봅니다. 정상 요청(viewer 의 읽기, editor 의 쓰기, admin 의 확인된 지우기)은 그대로 돼야 합니다.
단계
python3 /opt/fixtures/llmsec/kit.py agent로 재료를 펼친 뒤/root/llmsec/agent/app.py를 127.0.0.1:8103 에 띄워 두세요(0.0.0.0 금지, 터미널을 닫아도 살아 있게).curl 127.0.0.1:8103/health의 seed 가 펼칠 때 받은 씨앗과 같아야 합니다.- 지난 실행 기록
/root/llmsec/agent/data/history.log에서 성공한(result=ok) 실행만 보고, 역할(role)의 정책표에 없는 도구가 실행된 수와 사용자 홈 밖 경로(..로 홈 위로 나가거나 절대 경로)에 닿은 수를/root/llmsec/agent/review.txt에violations=<count>,escapes=<count>두 줄로 적으세요. 정책표는app.py머리말에 있습니다. allowed를 고쳐 도구 실행을 요청한 사용자의 역할로 허락하세요(정책표 그대로, 표에 없는 도구는 누구에게도 안 됨). 거절은data/audit.log에"event": "denied"로 남깁니다. viewer 의 읽기, editor 의 쓰기는 그대로 돼야 합니다.offered_tools를 고쳐 모델에게 그 사용자의 역할이 쓸 수 있는 도구만 보여 주세요. viewer 의 지우기 요청에는 지우기 호출이 아예 나오지 않고, 누구의 요청에도run_shell이 나오지 않아야 합니다. 모델이 목록 밖 이름을 지어내도 실행되지 않고 앱이 죽지 않아야 합니다.resolve를 고쳐 경로 인자를 실제 경로로 풀어 그 사용자의 홈(data/files/<name>/) 안일 때만 쓰게 하세요...·절대 경로·홈 밖을 가리키는 심볼릭 링크로는 남의 파일을 읽거나 쓰지 못하고, 홈 안의 경로(sub/../sub/x.txt같은 것 포함)는 그대로 됩니다.run_call·handle_confirm을 고쳐delete_file은 모델이 넣은confirm인자와 무관하게 늘 보류하세요: 응답actions에{"status": "pending", "id": <confirm-id>, ...}를 내고, 요청한 본인이POST /confirm {"id": ...}를 보낼 때 한 번만 지웁니다. 다른 사용자의 확인·두 번째 확인은 2xx 가 아닌 코드로 거절하고, 다른 사용자의 확인 시도는 보류를 없애지 않아야 합니다.incident ls-agent-overreach를 실행하면 사건이 납니다. 세 사람의 파일이 사라졌습니다(/srv/ls-agent/missing.txt). 사람마다 까닭을 가리고, 앱/srv/ls-agent/app.py를 실습 3~5단계처럼 고치고, 잘못 지워진 파일만 백업/srv/ls-agent/backup/files/에서 되살린 뒤/root/incident/ls-agent-overreach/report.txt에<owner>: <code>세 줄과evidence: <log line>을 적으세요. 코드는model-confirm(문맥 속 지시가 확인을 채움),owner-request(주인이 직접 확인하고 지움 — 사고 아님),path-escape(남의 홈으로 나간 경로),role-ignored(역할에 없는 지우기),shell-tool(셸 도구) 가운데 하나입니다.
참고
- 앱은
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()를 읽어 두세요. /run의 본문은{"request": "...", "context": ["<shared document line>", ...]}입니다. context 도 모델 입력에 들어갑니다.- 사건 도구:
incident list로 낼 수 있는 사건과 한 줄 증상을,incident show <name>으로 티켓을 다시 봅니다. 같은 사건을 다시 내면 새 씨앗으로 처음 고장 상태가 됩니다(대상의 원인도 다시 정해집니다). - 보고서는
/root/incident/<name>/report.txt입니다. 대상마다<name>: <cause-code>한 줄씩, 마지막에evidence: <log line>(원인을 보여 준 기록 한 줄) 을 그대로 붙입니다(로그·설정의 한 줄 통째로, 20자 이상). 채점기는 고친 코드에 새 무작위 공격을 다시 보내 보고(이 사건의 경로만이 아니라 같은 실습 앞 단계의 공격도 회귀로), 원인마다 다른 뒷정리가 됐는지 본 뒤에 보고서를 봅니다. 증거 줄은 사건 직후의 기록에 있던 줄이어야 하고, 그 대상의 사고 요청이나 새어 나간 값과 이어져야 합니다. - 흔한 실수: 경로 문자열에서
..만 찾아 막는 것. 절대 경로와 심볼릭 링크가 지나갑니다 — 실제 경로로 풀어 비교하세요.
앱을 펼치고 루프백에 띄운다
python3 /opt/fixtures/llmsec/kit.py agent 로 재료를 펼친 뒤 /root/llmsec/agent/app.py 를 127.0.0.1:8103 에 띄워 두세요(0.0.0.0 금지, 터미널을 닫아도 살아 있게). curl 127.0.0.1:8103/health 의 seed 가 펼칠 때 받은 씨앗과 같아야 합니다.
방향: 실습 앱은 바깥에 열 이유가 없습니다 — 듣는 주소가 곧 노출 범위입니다. 도구: ss -ltnp, curl, setsid·nohup. 구체: 펼치기 명령은 위 그대로입니다. 띄운 뒤 ss -ltn 에서 8103 번이 127.0.0.1 로 보이는지, /health 의 seed 가 /root/llmsec/agent/data/seed.txt 와 같은지 보세요.
지난 실행에서 역할 밖·홈 밖을 센다
지난 실행 기록 /root/llmsec/agent/data/history.log 에서 성공한(result=ok) 실행만 보고, 역할(role)의 정책표에 없는 도구가 실행된 수와 사용자 홈 밖 경로(.. 로 홈 위로 나가거나 절대 경로)에 닿은 수를 /root/llmsec/agent/review.txt 에 violations=<count>, escapes=<count> 두 줄로 적으세요. 정책표는 app.py 머리말에 있습니다.
방향: 고치기 전에 이미 무슨 일이 있었는지 셉니다. 정책은 「모델이 무엇을 골랐나」 가 아니라 「누구의 요청이었나」 로 봅니다. 도구: awk, grep, python3 의 os.path.normpath. 구체: 경로는 정규화한 뒤 판단하세요 — 가운데에 .. 가 있어도 홈 안으로 돌아오는 경로는 밖이 아닙니다. 실패한(result=error) 줄은 세지 않습니다.
요청한 사용자의 역할로 허락한다
allowed 를 고쳐 도구 실행을 요청한 사용자의 역할로 허락하세요(정책표 그대로, 표에 없는 도구는 누구에게도 안 됨). 거절은 data/audit.log 에 "event": "denied" 로 남깁니다. viewer 의 읽기, editor 의 쓰기는 그대로 돼야 합니다.
방향: 모델이 고른 도구 이름은 제안일 뿐입니다. 실행할지는 요청한 사람의 역할이 정합니다. 도구: allowed(user, tool), users.json 의 role. 구체: 정책표를 코드로 옮기고, 표에 없는 역할·도구는 거절하는 닫힌 기본값으로 쓰세요.
모델에게 보여 줄 도구를 줄인다
offered_tools 를 고쳐 모델에게 그 사용자의 역할이 쓸 수 있는 도구만 보여 주세요. viewer 의 지우기 요청에는 지우기 호출이 아예 나오지 않고, 누구의 요청에도 run_shell 이 나오지 않아야 합니다. 모델이 목록 밖 이름을 지어내도 실행되지 않고 앱이 죽지 않아야 합니다.
방향: 고르지 못한 도구는 실행될 일도 없습니다. 실행 직전의 검사(3단계)와 보여 주는 목록 줄이기는 서로 다른 층입니다. 도구: offered_tools, TOOLS, allowed. 구체: 보여 줄 목록을 역할로 거르세요. 지어낸 이름은 실행 쪽 검사가 거절하는지도 확인합니다.
경로는 실제 경로로 풀어 홈 안만
resolve 를 고쳐 경로 인자를 실제 경로로 풀어 그 사용자의 홈(data/files/<name>/) 안일 때만 쓰게 하세요. ..·절대 경로·홈 밖을 가리키는 심볼릭 링크로는 남의 파일을 읽거나 쓰지 못하고, 홈 안의 경로(sub/../sub/x.txt 같은 것 포함)는 그대로 됩니다.
방향: 문자열로 막으면 표기를 바꾼 경로가 지나갑니다. 파일시스템이 실제로 가리키는 곳을 봅니다. 도구: os.path.realpath, os.path.commonpath, os.path.isabs. 구체: 홈과 대상 모두 실제 경로로 푼 뒤 공통 조상이 홈인지 보세요. 거절은 예외로 던지면 run_call 이 error 로 돌려줍니다.
확인은 모델이 아니라 사람에게서
run_call·handle_confirm 을 고쳐 delete_file 은 모델이 넣은 confirm 인자와 무관하게 늘 보류하세요: 응답 actions 에 {"status": "pending", "id": <confirm-id>, ...} 를 내고, 요청한 본인이 POST /confirm {"id": ...} 를 보낼 때 한 번만 지웁니다. 다른 사용자의 확인·두 번째 확인은 2xx 가 아닌 코드로 거절하고, 다른 사용자의 확인 시도는 보류를 없애지 않아야 합니다.
방향: confirm: true 는 모델이 채우는 인자라 문서 속 지시가 넣을 수 있습니다. 확인은 모델이 쓸 수 없는 채널로 받습니다. 도구: PENDING, secrets.token_urlsafe, handle_confirm. 구체: 지우기 호출이 오면 실행 대신 id 를 만들어 누구의 것인지와 함께 보관하고, 확인 요청에서 본인 것일 때만 꺼내 실행한 뒤 지우세요. 확인 뒤에도 역할·경로 검사는 다시 거칩니다.
사건 — 세 사람의 파일이 사라졌다
incident ls-agent-overreach 를 실행하면 사건이 납니다. 파일 비서(운영판 /srv/ls-agent/app.py, 데이터 /srv/ls-agent/data/)를 쓰는 세 사람의 파일이 사라졌습니다 (목록 /srv/ls-agent/missing.txt, 기록 data/audit.log·data/runs.log — runs.log 에는 요청 문장, 사람이 보낸 확인(part=confirm), 함께 준 문맥, 모델이 고른 호출이 남습니다). 사람마다 까닭을 가리고 /srv/ls-agent/app.py 를 고치세요 — 실습 3~5단계의 결함(역할·도구 목록·경로)은 사건 원인과 무관하게 모두 막고, 모델이 채운 confirm 인자는 보지 않습니다 (admin 이 /run 요청 본문에 "confirm": true 를 붙여 자기 파일을 지우는 정상 사용은 그대로 둡니다). 그리고 잘못 지워진 파일만 백업 /srv/ls-agent/backup/files/ 에서 같은 내용으로 되살립니다 — 주인이 직접 확인하고 지운 것은 되살리지 않습니다. /root/incident/ls-agent-overreach/report.txt 에 <owner>: <code> 세 줄과 evidence: <log line> 을 적습니다. 코드는 사람마다 다음 중 하나입니다: model-confirm(문맥 속 지시가 확인을 채움), owner-request(주인이 직접 확인하고 지움 — 사고 아님), path-escape(남의 홈으로 나간 경로), role-ignored(역할에 없는 지우기), shell-tool(셸 도구).
방향: 「파일이 사라졌다」 는 같아도 누가, 무슨 문장과 문맥으로, 어떤 도구를 냈는지는 다릅니다. 그리고 모든 삭제가 사고는 아닙니다. 도구: grep, jq, audit.log 의 delete·shell, runs.log 의 part=request·confirm·context·call, users.json 의 role. 구체: 사라진 경로마다 audit.log 에서 그 파일에 닿은 기록과 rid 를 찾고, runs.log 에서 그 rid 의 요청 문장·문맥·호출을 보세요. 요청한 사람이 주인인지, 그 역할이 지우기를 할 수 있는지, 확인이 사람이 보낸 요청 필드(runs.log 의 part=confirm 줄)에서 왔는지, 모델이 문맥을 보고 채운 도구 인자에서 왔는지가 갈림길입니다. 문맥 줄에 「확인」 같은 낱말이 있어도 사람의 확인이 아닙니다.