Reproduce and stop the agent-drops-the-database incident
한국어 원문으로 표시합니다.
목표
run_sql 하나로 무엇이든 하는 MCP 서버가 DROP TABLE 을 그대로 실행하는 것을
재현한 뒤, 같은 서버를 세 겹으로 고칩니다 — 읽기 전용 모드, 허용 목록, 파괴적
도구의 확인 단계.
왜 중요한가
제목의 사고는 모델이 나빠서 나는 것이 아닙니다. "테스트 데이터를 정리해 줘"
라는 말을 받은 모델이 가장 짧은 길인 DROP TABLE 을 고른 것뿐이고, 그것을
막을 장치가 서버에 하나도 없었던 것이 원인입니다. 스펙은 도구를 모델이
고르는 것(model-controlled) 이라 부르고, 그래서 사람이 거부할 수 있어야
하며 서버는 접근 제어를 구현해야 한다고 적습니다. 그 문장을 코드로 옮기면
세 가지가 됩니다. 도구를 좁게 쪼개고 목록으로 켜고 끈다, 쓰기가 필요 없는
자리는 연결 자체를 읽기 전용으로 연다, 지우는 도구는 확인 없이는 돌지
않는다. 이 실습에서 셋을 전부 손으로 넣습니다.
단계
/root/mcp/guard/seed.sql을 저장하고/root/mcp/guard/shop.db로 적재하세요. customers 5행, orders 8행이어야 합니다./root/mcp/guard/server_v1.py를 만드세요. 도구는run_sql(인자sql) 하나이고, SELECT 는 결과 행을, 그 밖의 SQL 은 실행한 뒤ok를 돌려줍니다. DB 경로는 환경변수MCP_DB(기본/root/mcp/guard/shop.db)입니다./root/mcp/guard/attack.sh로 사고를 재현하세요. v1 서버에run_sql로DROP TABLE orders;를 보내고, 그 응답 JSON 과tables_after=<남은 테이블 목록>한 줄을/root/mcp/guard/incident.txt에 남깁니다. orders 가 정말 사라져야 합니다.- DB 를 seed.sql 로 복구하고
/root/mcp/guard/postmortem.md에cause=,missing_control=,fix=세 줄(각각 한 문장 이상)을 쓰세요. /root/mcp/guard/server_v2.py— 환경변수MCP_READ_ONLY=1이면 DB 를 읽기 전용으로 열어서,run_sql로DROP을 보내도isError: true로 거절되고 테이블이 남아야 합니다. SELECT 는 그대로 됩니다./root/mcp/guard/server_v3.py와/root/mcp/guard/allowlist.json—run_sql을 없애고list_customers·count_orders·delete_order세 도구를 정의하되, 환경변수MCP_ALLOWLIST(기본 allowlist.json)에 적힌 이름만tools/list에 내고 호출을 받습니다. allowlist.json 에는 읽기 도구 둘만 넣으세요. 목록 밖 도구 호출은-32602입니다.- v3 의
delete_order(인자id,confirm)는confirm이true가 아니면 아무것도 지우지 않고isError: true로 무엇을 지우려 했는지 알려 주고,true일 때만 지웁니다. /root/mcp/guard/attack_v3.sh로 같은 공격을 v3 에 보내 막히는 것을 확인하고,/root/mcp/guard/guard-report.txt에run_sql_removed=yes,readonly_blocks_drop=yes,delete_requires_confirm=yes,orders_rows=<현재 orders 행 수>네 줄을 쓰세요.
참고
- 읽기 전용 연결:
sqlite3.connect(f"file:{경로}?mode=ro", uri=True). 쓰기를 시도하면 sqlite 가attempt to write a readonly database로 거부합니다 — SQL 문자열을 검사해 막는 방식은 따라가야 할 변형이 끝없이 늘어나니 엔진에서 막으세요. - 채점기는 파괴적 도구를 시험할 때 학생의 DB 를 임시 사본으로 복사해
MCP_DB로 넘깁니다. 서버가 그 환경변수를 읽지 않으면 채점이 실제 DB 를 지우거나 실패합니다. - 허용 목록은 "있는 도구 중 켤 것" 이 아니라 "목록에 없으면 없는 도구" 입니다.
tools/list에도 나오지 않고tools/call도 모르는 도구로 답해야 합니다. - 흔한 실수 1: confirm 을 문자열
"true"로 받아들이는 것. 스키마가 boolean 이면is True로 비교하세요. - 흔한 실수 2: 목록이 없을 때 모든 도구를 여는 것. 없으면 아무것도 열지 않는 쪽이 닫힌 기본값입니다.
가게 DB 를 만든다
/root/mcp/guard/seed.sql 을 저장하고 /root/mcp/guard/shop.db 로 적재하세요. customers 5행, orders 8행이어야 합니다.
sqlite3 는 sqlite3 shop.db < seed.sql 로 파일을 통째로 실행합니다. 파이썬으로 하려면 sqlite3.connect(...).executescript(open(...).read()) 입니다. 이미 있는 DB 에 다시 적재하면 테이블이 있다는 오류가 나니 먼저 지우세요.
만능 도구가 있는 서버
/root/mcp/guard/server_v1.py 를 만드세요. 도구는 run_sql(인자 sql) 하나이고, SELECT 는 결과 행을, 그 밖의 SQL 은 실행한 뒤 ok 를 돌려줍니다. DB 경로는 환경변수 MCP_DB(기본 /root/mcp/guard/shop.db)입니다.
앞 실습의 서버에서 도구를 run_sql 하나로 바꾸면 됩니다. SELECT 로 시작하면 execute().fetchall(), 아니면 executescript() 뒤 commit(). sqlite 오류는 isError: true 로 돌려주세요. 이 서버는 일부러 위험하게 둡니다 — 다음 단계에서 그 결과를 봅니다.
사고를 재현한다
/root/mcp/guard/attack.sh 로 사고를 재현하세요. v1 서버에 run_sql 로 DROP TABLE orders; 를 보내고, 그 응답 JSON 과 tables_after=<남은 테이블 목록> 한 줄을 /root/mcp/guard/incident.txt 에 남깁니다. orders 가 정말 사라져야 합니다.
printf 로 요청 두 줄(initialize, tools/call)을 만들어 python3 server_v1.py 에 파이프하고, 출력과 함께 남은 테이블 이름을 sqlite_master 에서 읽어 한 줄 덧붙입니다. 응답에 isError 가 없고 ok 가 오는 것 — 서버가 아무 저항 없이 지웠다는 증거 — 를 파일에 남기세요.
복구하고 사고 기록을 쓴다
DB 를 seed.sql 로 복구하고 /root/mcp/guard/postmortem.md 에 cause=, missing_control=, fix= 세 줄(각각 한 문장 이상)을 쓰세요.
복구는 1단계와 같습니다(파일을 지우고 다시 적재). 사고 기록은 세 질문에 답합니다 — 무엇이 원인이었나(도구 설계), 어떤 장치가 없었나(허용 목록·읽기 전용·확인), 무엇을 바꿀 것인가.
읽기 전용 모드
/root/mcp/guard/server_v2.py — 환경변수 MCP_READ_ONLY=1 이면 DB 를 읽기 전용으로 열어서, run_sql 로 DROP 을 보내도 isError: true 로 거절되고 테이블이 남아야 합니다. SELECT 는 그대로 됩니다.
sqlite3.connect(f"file:{DB}?mode=ro", uri=True) 로 열면 쓰기 문장이 sqlite 오류로 실패합니다. 그 예외를 잡아 isError: true 텍스트로 돌려주세요. 문자열에 DROP 이 있는지 검사하는 방식은 따라갈 변형이 끝이 없으니 연결 자체를 막는 쪽을 택합니다.
허용 목록으로 좁힌다
/root/mcp/guard/server_v3.py 와 /root/mcp/guard/allowlist.json — run_sql 을 없애고 list_customers·count_orders·delete_order 세 도구를 정의하되, 환경변수 MCP_ALLOWLIST(기본 allowlist.json)에 적힌 이름만 tools/list 에 내고 호출을 받습니다. allowlist.json 에는 읽기 도구 둘만 넣으세요. 목록 밖 도구 호출은 -32602 입니다.
도구 정의 전체(ALL_TOOLS)와 목록 파일을 읽어 거른 TOOLS 를 나누세요. tools/call 에서도 TOOLS 에 없는 이름은 모르는 도구로 답합니다. 목록 파일이 없거나 깨졌으면 빈 집합 — 아무 도구도 내지 않는 것이 안전한 기본값입니다.
지우는 도구는 확인이 필요하다
v3 의 delete_order(인자 id, confirm)는 confirm 이 true 가 아니면 아무것도 지우지 않고 isError: true 로 무엇을 지우려 했는지 알려 주고, true 일 때만 지웁니다.
args.get("confirm") is True 로 비교하세요. 거절할 때는 지우려던 행(상태·금액)을 텍스트에 담아 사람이 판단할 재료를 주고, 지웠을 때는 rowcount 를 돌려주면 됩니다. 채점기는 임시 사본 DB 에 MCP_DB 로 넘겨 시험하고, 목록에 delete_order 를 넣은 임시 allowlist 를 MCP_ALLOWLIST 로 줍니다.
같은 공격이 막히는 것을 증명한다
/root/mcp/guard/attack_v3.sh 로 같은 공격을 v3 에 보내 막히는 것을 확인하고, /root/mcp/guard/guard-report.txt 에 run_sql_removed=yes, readonly_blocks_drop=yes, delete_requires_confirm=yes, orders_rows=<현재 orders 행 수> 네 줄을 쓰세요.
attack.sh 를 복사해 v3 로 바꾸고 delete_order 를 confirm 없이 부르는 요청을 한 줄 더하세요. 응답에서 run_sql 은 -32602, delete_order 는 isError 가 나와야 합니다. 행 수는 SELECT COUNT(*) FROM orders 로 세어 그대로 적습니다.