TT Lab
블로그 시작하기
배우기 러닝패스 코스

LLM 앱 보안 — 주입에서 출력 처리까지

모델 출력이 SQL·HTML·셸로 흐를 때 — 그리고 코스를 묶는 사건

TT Lab 에서 이어서 보기

목표

모델의 출력을 SQL·HTML·셸·DB 쓰기에 그대로 넘기는 보고서 도우미를 받는 쪽의 방어로 고치고, 코스 전체의 경로가 섞인 쿠폰 유출 사건을 가립니다.

왜 중요한가

모델의 출력은 공격자가 쓸 수 있는 값입니다(앞 모듈의 주입). 그 값을 다음 시스템에 그대로 넘기면 SQL 주입·XSS·명령 주입이 모델을 거쳐 돌아옵니다. 방어는 모델이 아니라 받는 쪽에 겁니다 — 서버가 가진 질의를 이름으로 고르게 하기, 자리표시자, 읽기 전용 연결, 문맥에 맞는 이스케이프, 셸 없는 실행, 스키마 검증. 이 실습의 가짜 모델은 지시가 있으면 늘 공격자의 글을 출력하고, 진짜 모델은 그런 출력을 가끔 냅니다. 받는 쪽 방어는 그 가끔이 언제 오든 같은 결과를 냅니다. 채점기는 채점할 때마다 새 DB·쿠폰·표식으로 앱을 띄워 DB 가 바뀌는지, 쿠폰이 나오는지, 태그가 그려지는지, 표식 파일이 생기는지 보고, 정상 질문의 결과가 맞는지도 봅니다.

단계

  1. python3 /opt/fixtures/llmsec/kit.py report 로 재료를 펼친 뒤 /root/llmsec/report/app.py 를 127.0.0.1:8104 에 띄워 두세요(0.0.0.0 금지, 터미널을 닫아도 살아 있게). curl 127.0.0.1:8104/health 의 seed 가 펼칠 때 받은 씨앗과 같아야 합니다.
  2. 질의 기록 /root/llmsec/report/data/history.log 의 sql= 뒤(모델이 낸 SQL)를 보고, SELECT 한 문장이 아닌 것(여러 문장이거나 SELECT 가 아닌 것)의 수와 coupons 표를 건드린 것의 수를 /root/llmsec/report/flow.txt 에 non_select=<count>, coupon_reads=<count> 두 줄로 적으세요.
  3. plan_query 와 /root/llmsec/report/templates.txt 를 고쳐 모델의 출력을 SQL 로 실행하지 마세요 — 모델은 서버가 가진 질의 가운데 하나를 이름으로 고르고, 값은 매개변수로, 연결은 읽기 전용으로 엽니다. 「지역별 매출 합계」·「월별 매출 합계」·「상위 고객 다섯」 세 질문의 결과는 지금과 같아야 합니다.
  4. render_page 를 고쳐 /page 의 질문(q)과 모델 답을 HTML 에 끼우기 전에 이스케이프하세요. 태그는 글자로 보여야 하고(지우지 않음), 정상 도움말 답은 그대로 보여야 합니다.
  5. export_file 을 고쳐 모델이 고른 파일 이름을 규칙(글자·숫자·-·_ 1~40자 + .csv)으로 검증하고, 셸을 거치지 않고 data/out/ 에 복사하세요. 규칙에 맞는 이름은 그대로 내보내야 합니다.
  6. save_note 를 고쳐 모델이 정리한 메모를 스키마(문자열, 1~300자, customers 에 있는 고객)로 검증한 뒤 자리표시자로 저장하세요. 맞지 않으면 저장하지 않고 4xx 로 답합니다. 작은따옴표가 든 정상 메모도 글자 그대로 저장돼야 합니다.
  7. incident ls-coupon-leak 을 실행하면 사건이 납니다. 쿠폰 코드 셋이 바깥에서 쓰였습니다(/srv/ls-report/found.txt). 쿠폰 주인마다 경로를 가리고, /srv/ls-report/app.py 를 실습 3~6단계처럼 고치고, 원천을 걷어 내고, 새어 나간 쿠폰만 새 코드로 바꾼 뒤 /root/incident/ls-coupon-leak/report.txt 에 <coupon-owner>: <code> 세 줄과 evidence: <log line> 을 적으세요. 코드는 log-bundle(협력사에 보낸 기록 묶음), prompt-extract(시스템 문장 추출), shell-export(내보내기 이름 속 셸 명령), sql-from-model(모델이 쓴 SQL) 가운데 하나입니다.

참고

앱을 펼치고 루프백에 띄운다

python3 /opt/fixtures/llmsec/kit.py report 로 재료를 펼친 뒤 /root/llmsec/report/app.py 를 127.0.0.1:8104 에 띄워 두세요(0.0.0.0 금지, 터미널을 닫아도 살아 있게). curl 127.0.0.1:8104/health 의 seed 가 펼칠 때 받은 씨앗과 같아야 합니다.

방향: 실습 앱은 바깥에 열 이유가 없습니다 — 듣는 주소가 곧 노출 범위입니다. 도구: ss -ltnp, curl, setsid·nohup. 구체: 펼치기 명령은 위 그대로입니다. 띄운 뒤 ss -ltn 에서 8104 번이 127.0.0.1 로 보이는지, /health 의 seed 가 /root/llmsec/report/data/seed.txt 와 같은지 보세요.

모델이 낸 SQL 을 센다

질의 기록 /root/llmsec/report/data/history.log 의 sql= 뒤(모델이 낸 SQL)를 보고, SELECT 한 문장이 아닌 것(여러 문장이거나 SELECT 가 아닌 것)의 수와 coupons 표를 건드린 것의 수를 /root/llmsec/report/flow.txt 에 non_select=<count>, coupon_reads=<count> 두 줄로 적으세요.

방향: 모델이 무엇을 실행시켰는지가 이미 기록에 있습니다. 「모델이 썼으니 괜찮다」 가 얼마나 자주 틀렸는지 셉니다. 도구: grep, awk -F'sql=', python3. 구체: 세미콜론으로 문장을 나눠 빈 것을 빼고 셉니다. 두 수는 서로 겹칠 수 있습니다(쿠폰을 읽는 SELECT 한 문장도 있습니다).

모델은 질의를 고르기만 한다

plan_query 와 /root/llmsec/report/templates.txt 를 고쳐 모델의 출력을 SQL 로 실행하지 마세요 — 모델은 서버가 가진 질의 가운데 하나를 이름으로 고르고, 값은 매개변수로, 연결은 읽기 전용으로 엽니다. 「지역별 매출 합계」·「월별 매출 합계」·「상위 고객 다섯」 세 질문의 결과는 지금과 같아야 합니다.

방향: 모델이 SQL 을 쓰는 한 그 SQL 은 공격자의 것일 수 있습니다. SQL 은 서버가 갖고, 모델은 이름을 고르게 바꿉니다. 도구: templates.txt, 사전(이름 → SQL), sqlite3.connect(f"file:{path}?mode=ro", uri=True), 자리표시자 ?. 구체: templates.txt 의 | 뒤를 SQL 대신 이름으로 바꾸고, plan_query 는 그 이름이 사전에 있을 때만 서버의 SQL 을 실행하게 하세요. 모르는 이름은 4xx 입니다.

답을 끼우기 전에 이스케이프한다

render_page 를 고쳐 /page 의 질문(q)과 모델 답을 HTML 에 끼우기 전에 이스케이프하세요. 태그는 글자로 보여야 하고(지우지 않음), 정상 도움말 답은 그대로 보여야 합니다.

방향: 브라우저는 어디서 온 글인지 모릅니다. 모델 답도, 되비춘 질문도 끼우는 자리의 문맥에 맞게 바꿉니다. 도구: html.escape. 구체: 두 자리 모두 감싸세요. 태그를 지우는 방식은 정상 글자까지 망가뜨리고 변형을 놓칩니다.

이름은 규칙으로, 복사는 셸 없이

export_file 을 고쳐 모델이 고른 파일 이름을 규칙(글자·숫자·-·_ 1~40자 + .csv)으로 검증하고, 셸을 거치지 않고 data/out/ 에 복사하세요. 규칙에 맞는 이름은 그대로 내보내야 합니다.

방향: 문자열을 셸에 넘기는 순간 그 문자열의 문법이 셸의 문법이 됩니다. 셸이 필요 없는 일은 셸 없이 합니다. 도구: re.fullmatch, shutil.copyfile, subprocess 의 인자 목록. 구체: 허용 규칙에 맞지 않으면 4xx 로 거절하세요(고쳐서 받아 주지 않기). 규칙에 경로 구분자가 없으면 경로 탈출도 함께 막힙니다.

스키마를 통과한 것만 자리표시자로 저장한다

save_note 를 고쳐 모델이 정리한 메모를 스키마(문자열, 1~300자, customers 에 있는 고객)로 검증한 뒤 자리표시자로 저장하세요. 맞지 않으면 저장하지 않고 4xx 로 답합니다. 작은따옴표가 든 정상 메모도 글자 그대로 저장돼야 합니다.

방향: 모델이 형식을 지켰다고 믿지 않습니다. 형·길이·참조를 확인하고, 값은 SQL 문자열 밖으로 넘깁니다. 도구: isinstance, len, SELECT 1 … WHERE name = ?, INSERT … VALUES (?, ?). 구체: executescript 는 자리표시자를 받지 않습니다 — execute 로 바꾸세요. 고객 이름도 요청에서 온 값이라 같은 대우를 합니다.

사건 — 쿠폰 셋이 바깥에서 쓰였다

incident ls-coupon-leak 을 실행하면 사건이 납니다. 보고서 도우미(운영판 /srv/ls-report/app.py, 데이터 /srv/ls-report/data/)의 쿠폰 코드 셋이 바깥에서 쓰였습니다 (발견된 조각 /srv/ls-report/found.txt, 기록 data/app.log, 협력사에 보낸 묶음 /srv/ls-report/logs/vendor-bundle.log). 쿠폰 주인마다 경로를 가리고, /srv/ls-report/app.py 를 실습 3~6단계처럼 고치고(질의·화면·내보내기·메모 — 채점기가 그 공격을 모두 다시 보냅니다, 정상 사용은 그대로), 원천을 걷어 내세요 — 시스템 문장의 쿠폰은 빼고, 협력사 묶음은 줄을 남긴 채 쿠폰만 가리고, 공개 디렉터리(data/public/)에 생긴 파일은 지웁니다. 그리고 coupons 표에서 새어 나간 쿠폰만 새 코드(CP- + 영대문자·숫자 6자 이상, 다른 쿠폰과 겹치지 않게)로 바꿉니다(행은 남기고, 새지 않은 쿠폰은 그대로). /root/incident/ls-coupon-leak/report.txt 에 <coupon-owner>: <code> 세 줄과 evidence: <log line> 을 적습니다. 코드는 주인마다 다음 중 하나입니다: log-bundle(협력사에 보낸 기록 묶음), prompt-extract(시스템 문장 추출), shell-export(내보내기 이름 속 셸 명령), sql-from-model(모델이 쓴 SQL).

방향: 코스에서 본 출구가 한자리에 모였습니다 — 입력(주입), 비밀(시스템 문장·기록), 출력(SQL·셸). 조각의 모양이 어느 출구를 지났는지 말해 줍니다. 도구: grep -F, jq, app.log 의 kind·sql·cmd·answer, python3 -c 'import sqlite3 …'. 구체: 조각마다 그대로 찾아보세요 — 질의 결과 행 모양이면 kind=query 의 sql 을, 시스템 문장의 줄이면 kind=page 의 answer 를, CSV 한 줄이면 kind=export 의 cmd 와 data/public 을, 질문 문장이면 협력사 묶음을. 고침은 실습 3·5단계와 같은 생각이고, 쿠폰 바꾸기는 UPDATE … WHERE owner = ? 로 하나씩입니다.