LabHub
배우기 러닝패스 코스

エージェントが私のDBを消した

エージェントによるDB削除事故を再現して防ぐ

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

run_sql 하나로 무엇이든 하는 MCP 서버가 DROP TABLE 을 그대로 실행하는 것을 재현한 뒤, 같은 서버를 세 겹으로 고칩니다 — 읽기 전용 모드, 허용 목록, 파괴적 도구의 확인 단계.

왜 중요한가

제목의 사고는 모델이 나빠서 나는 것이 아닙니다. "테스트 데이터를 정리해 줘" 라는 말을 받은 모델이 가장 짧은 길인 DROP TABLE 을 고른 것뿐이고, 그것을 막을 장치가 서버에 하나도 없었던 것이 원인입니다. 스펙은 도구를 모델이 고르는 것(model-controlled) 이라 부르고, 그래서 사람이 거부할 수 있어야 하며 서버는 접근 제어를 구현해야 한다고 적습니다. 그 문장을 코드로 옮기면 세 가지가 됩니다. 도구를 좁게 쪼개고 목록으로 켜고 끈다, 쓰기가 필요 없는 자리는 연결 자체를 읽기 전용으로 연다, 지우는 도구는 확인 없이는 돌지 않는다. 이 실습에서 셋을 전부 손으로 넣습니다.

단계

  1. /root/mcp/guard/seed.sql 을 저장하고 /root/mcp/guard/shop.db 로 적재하세요. customers 5행, orders 8행이어야 합니다.
  2. /root/mcp/guard/server_v1.py 를 만드세요. 도구는 run_sql(인자 sql) 하나이고, SELECT 는 결과 행을, 그 밖의 SQL 은 실행한 뒤 ok 를 돌려줍니다. DB 경로는 환경변수 MCP_DB(기본 /root/mcp/guard/shop.db)입니다.
  3. /root/mcp/guard/attack.sh 로 사고를 재현하세요. v1 서버에 run_sqlDROP TABLE orders; 를 보내고, 그 응답 JSON 과 tables_after=<남은 테이블 목록> 한 줄을 /root/mcp/guard/incident.txt 에 남깁니다. orders 가 정말 사라져야 합니다.
  4. DB 를 seed.sql 로 복구하고 /root/mcp/guard/postmortem.mdcause=, missing_control=, fix= 세 줄(각각 한 문장 이상)을 쓰세요.
  5. /root/mcp/guard/server_v2.py — 환경변수 MCP_READ_ONLY=1 이면 DB 를 읽기 전용으로 열어서, run_sqlDROP 을 보내도 isError: true 로 거절되고 테이블이 남아야 합니다. SELECT 는 그대로 됩니다.
  6. /root/mcp/guard/server_v3.py/root/mcp/guard/allowlist.jsonrun_sql 을 없애고 list_customers·count_orders·delete_order 세 도구를 정의하되, 환경변수 MCP_ALLOWLIST(기본 allowlist.json)에 적힌 이름만 tools/list 에 내고 호출을 받습니다. allowlist.json 에는 읽기 도구 둘만 넣으세요. 목록 밖 도구 호출은 -32602 입니다.
  7. v3 의 delete_order(인자 id, confirm)는 confirmtrue 가 아니면 아무것도 지우지 않고 isError: true 로 무엇을 지우려 했는지 알려 주고, true 일 때만 지웁니다.
  8. /root/mcp/guard/attack_v3.sh 로 같은 공격을 v3 에 보내 막히는 것을 확인하고, /root/mcp/guard/guard-report.txtrun_sql_removed=yes, readonly_blocks_drop=yes, delete_requires_confirm=yes, orders_rows=<현재 orders 행 수> 네 줄을 쓰세요.

참고

가게 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_sqlDROP 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.mdcause=, missing_control=, fix= 세 줄(각각 한 문장 이상)을 쓰세요.

복구는 1단계와 같습니다(파일을 지우고 다시 적재). 사고 기록은 세 질문에 답합니다 — 무엇이 원인이었나(도구 설계), 어떤 장치가 없었나(허용 목록·읽기 전용·확인), 무엇을 바꿀 것인가.

읽기 전용 모드

/root/mcp/guard/server_v2.py — 환경변수 MCP_READ_ONLY=1 이면 DB 를 읽기 전용으로 열어서, run_sqlDROP 을 보내도 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.jsonrun_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)는 confirmtrue 가 아니면 아무것도 지우지 않고 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.txtrun_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 로 세어 그대로 적습니다.