관련 프로젝트CJ AI Security Wave 최종 프로젝트(AI를 활용한 침투테스트) →
시리즈AI를 보안 분석에 붙여 본 기록4편 · 공개 5편
LLM에 판정을 맡기지 않고 설명만 맡기면 검증이 어디로 옮겨가나
규칙 탐지와 설명 생성, 출력 검증, 보고서 생성을 분리한 파이프라인을 모의 LLM으로 돌려 보고, 프롬프트 인젝션의 다층 방어에서 결정을 실제로 잠근 것이 무엇이었는지, 스키마가 맞는 출력과 사실인 출력이 왜 다른 검사인지, 정규식 코드 스캐너가 두 번째 파일에서 무엇을 놓쳤는지 확인한 실습 기록.
목차 12
판정을 모델에 맡기지 않고 설명만 맡겼다
LLM을 보안 분석에 붙이는 방법은 두 가지로 갈린다. 탐지 결과를 모델에 통째로 넘겨 판정까지 받는 방식과, 규칙이나 분석기가 만든 근거를 구조화한 뒤 모델에는 설명과 요약만 맡기는 방식이다. 이번 실습은 두 번째 구조를 만들어 보는 것이었다. 최종 판정은 원본 로그, 결정적 규칙, 출력 검증과 분석가 검토를 거치고, 모델이 만든 문장이 자연스럽다는 사실은 그 내용이 맞다는 증거로 쓰지 않는다.
먼저 밝혀 둘 조건이 있다. 실습의 mock_llm.py는 실제 언어 모델을 호출하지 않는다. 정규표현식과 미리 정의한 문장으로 출력을 흉내 낸다. 그래서 이 글에서 확인한 것은 파이프라인 구조와 검증 절차가 돌아가는지까지이고, 실제 모델의 비결정성, 지연, 비용, 잘린 응답, 처음 보는 공격 입력에 대한 견고성은 재 본 적이 없다.
파이프라인 단계마다 실패했을 때 무엇을 하는가
원본 로그
-> 규칙 기반 탐지와 근거 추출
-> 설명 생성 계층(mock LLM 또는 실제 LLM)
-> 스키마와 의미 검증
-> 규칙 결과와 교차 검증
-> HTML 보고서
-> 분석가 검토| 단계 | 주요 입력 | 출력 | 실패 시 처리 |
|---|---|---|---|
| 탐지 | 요청, 상태 코드, 규칙 | 의심 이벤트와 탐지 근거 | 원본 로그를 보존하고 규칙 범위를 재검토 |
| 설명 | 구조화된 탐지 결과 | 요약, 심각도, 대응 제안 | 모델 출력을 판정 근거로 단독 사용하지 않음 |
| 검증 | 설명 결과, 허용된 스키마 | 승인·검토·거부 상태 | 잘못된 형식과 불일치는 검토 대기열로 격리 |
| 보고 | 검증된 필드 | HTML 보고서 | 출력 문맥에 맞는 인코딩과 템플릿 검증 |
오른쪽 칸이 이 표의 요점이다. 각 단계가 성공했을 때 무엇을 넘기는지보다, 실패했을 때 그 결과가 다음 단계로 흘러가지 않게 어디에 세워 두는지가 파이프라인의 성격을 정한다.
규칙 4건, 모의 설명 5건, 보고서 하나
제공된 코드를 임시 복사본에서 실행하니 예제 로그 50건 가운데 규칙이 4건을 의심 이벤트로 분류했다. 모의 설명기는 5개 예제에 대해 HIGH 3건, MEDIUM 1건, LOW 1건을 만들었고 Jinja2 보고서는 정상적으로 생성됐다. 두 숫자는 입력이 달라서 4건이 5건 안에 들어 있는지는 확인하지 않았다. 탐지 4건과 설명 5건을 한 흐름의 앞뒤로 읽으면 안 되는 이유다.
합성 데이터와 고정 규칙에서 나온 수치라 운영 정확도로 읽지 않는다. 규칙 자체에도 눈에 띄는 구멍이 있다. /admin 접근을 폭넓게 악성으로 분류하기 때문에 스캔과 인증 실패, 정상 관리 행위가 한 칸에 들어간다. 관리자가 실제로 관리 페이지에 들어간 요청도 4건 안에 섞일 수 있는 규칙이다.
자동 이스케이프가 막아 준 것과 막지 않는 것
Jinja2 자동 이스케이프가 켜진 상태에서 보고서를 만들자 예제 <script> 문자열이 <script>로 출력됐고 실행 가능한 원문은 HTML에 들어가지 않았다. 탐지 로그를 보고서로 옮기는 과정에서 공격 페이로드가 그대로 실행되는 상황은 이 설정으로 막힌다.
다만 이것은 HTML 본문 문맥 하나에 대한 방어다. 같은 값을 URL, JavaScript, CSS, 셸 명령, SQL, Markdown에 넣을 때는 각 문맥의 인코딩과 허용 목록, 안전한 API를 따로 붙여야 한다. 자동 이스케이프를 켜 두었다는 사실 하나로 보고서 생성 경로 전체가 안전해지지는 않는다.
지시와 데이터를 갈라 놓는 경계가 LLM에는 없다
프롬프트 인젝션은 LLM 애플리케이션이 신뢰할 수 있는 지시와 외부에서 들어온 데이터를 충분히 구분하지 못하도록 입력을 구성해, 원래 정책과 다른 응답이나 도구 동작을 끌어내는 위협이다. 사용자가 직접 명령을 치는 경우만이 아니라 웹 문서, 파일, 이메일, 검색 결과와 RAG 문서에 숨은 지시를 모델이 읽는 간접 인젝션도 여기에 들어간다.
SQL Injection에는 파라미터 바인딩이라는 답이 있다. 명령과 데이터를 다른 통로로 보내기 때문이다. 현재 LLM에는 그에 해당하는 범용 해법이 없다. 구분자와 시스템 프롬프트는 모델에게 의도를 알려 주는 완화책이고, 권한 통제와 독립된 검증을 대신하는 보안 경계가 아니다.
| 형태 | 특징 | 방어 시 확인할 점 |
|---|---|---|
| 직접 인젝션 | 사용자 입력에 정책 변경이나 이전 지시 무시를 요구 | 입력 목적 제한, 패턴 탐지, 민감 작업 승인 |
| 간접 인젝션 | 모델이 읽는 문서나 외부 데이터에 지시를 삽입 | 출처, 신뢰 등급, 콘텐츠 격리와 검색 범위 |
| 인코딩·난독화 | Base64, 유니코드, 제로폭 문자 등으로 패턴을 변형 | 제한된 정규화·디코딩과 원문 보존 |
| 다국어·철자 변형 | 언어 전환이나 철자 교란으로 단순 차단 규칙을 우회 | 의미 기반 분류와 다양한 회귀 테스트 |
| 도구 호출 유도 | 모델의 파일·네트워크·업무 도구 권한을 악용 | 최소 권한, 인자 검증, 사람의 승인 |
여섯 겹으로 쌓아도 마지막은 권한이다
- 입력 정규화와 검사 — 유니코드 정규화, 제로폭 문자 처리, 제한된 디코딩을 하고 원문과 변환 이력을 함께 남긴다.
- 지시와 데이터 분리 — 외부 콘텐츠를 명확한 필드와 구분자로 감싸고 데이터 안의 지시를 실행하지 않는다는 정책을 전달한다.
- 결정적 정책 우선 — 차단, 권한, 심각도 같은 핵심 결정은 코드나 정책 엔진에서 먼저 계산하고 LLM에는 설명 역할만 준다.
- 출력 검증 — 허용된 스키마, 값의 범위, 근거 인용, 도구 인자를 검사한다.
- 권한과 실행 통제 — 도구별 최소 권한, 중요 동작의 사람 승인, 실행 직전 재검증, 감사 로그를 적용한다.
- 지속적인 공격 테스트 — 직접·간접·난독화 입력과 정상 경계 사례를 회귀 테스트로 유지한다.
이 가운데 1·2·4번은 모두 입력이나 출력의 생김새를 검사하는 층이라, 처음 보는 표현 앞에서 함께 약해진다. 정규식 블랙리스트는 알려진 문구에만 걸리고, 자동 디코딩은 정상 문자열을 오인하거나 깊이를 제한하지 않으면 자원 고갈로 이어진다. 보조 가드 모델도 결국 공격 입력을 처리하는 모델이라 단독 신뢰점이 되지 못한다. 모델이 도구를 쓸 수 있는 구성에서는 프롬프트 필터가 뚫렸을 때보다 권한·승인·인자 검증이 뚫렸을 때의 피해가 크다.
ground-first를 잠근 것은 모델이 아니라 정규식이었다
실습의 injection_defense_full.py는 위 흐름을 정규식 기반 모의 구현으로 보여 준다. 여기서 이름 때문에 오해하기 쉬운 단계가 ground-first다. LLM이 근거를 우선하도록 학습되었거나 지시를 무시하도록 정렬된 것이 아니라, ATTACK_SIG 정규식이 판정을 미리 잠가 두는 결정적 규칙이다. 모델이 무슨 말을 만들든 그 앞에서 결과가 정해진다.
이 사실을 확인하고 나면 실습 결과의 의미가 달라진다. 인젝션을 막은 것은 LLM 계층이 아니라 그 앞의 규칙이었고, 규칙이 모르는 표현이 들어오면 남는 층은 권한과 승인뿐이다. 이 코드는 LLM의 견고성을 보여 주는 예가 아니라 규칙과 LLM을 결합한 하이브리드 구조의 예로 읽어야 한다.
형식이 맞는 출력과 사실인 출력은 다른 검사다
LLM 출력 검증을 ’JSON이 파싱되는지 보는 일’로 줄이면 절반이 빈다. 구조가 맞아도 내용이 틀릴 수 있고, 값이 스키마 안에 있어도 위험한 명령일 수 있다.
| 단계 | 검증 대상 | 실패 처리 |
|---|---|---|
| 구문 | JSON 파싱 가능 여부 | 응답 폐기 또는 제한된 재시도 |
| 스키마 | 필수 필드, 자료형, 추가 필드 | 검토 대기열로 격리 |
| 값 | 심각도 허용 목록, 길이와 범위 | 알 수 없는 값을 임의의 낮은 등급으로 바꾸지 않음 |
| 근거 | 원본 로그, 규칙 결과, 인용과 결론의 일치 | 불일치 사유를 기록하고 분석가 검토 |
| 실행 | 도구 이름, 인자, 대상과 권한 | 정책 엔진과 사람의 승인 후 실행 |
verify_flag.py를 실행해 규칙 결과와 모의 LLM 결과가 일치하면 승인하고, 과소평가와 과대평가에는 REVIEW를 주는 흐름을 확인했다. CRITICAL처럼 허용 목록에 없는 값, 심각도 누락, 잘못된 자료형, 객체가 아닌 출력은 예외 없이 INVALID로 떨어졌다.
규칙과 모델이 같은 답을 냈다고 해서 그 답이 참인 것은 아니다. 두 분석기가 같은 특징을 보거나 같은 가정을 공유하면 같은 자리에서 같이 틀린다. 여러 탐지기의 표를 세는 앙상블에 붙는 조건이 여기에도 그대로 붙는다. 모델들의 오류가 서로 다를 때만 표 수가 신뢰도가 되고, 두 판정이 일치한다는 사실 자체는 원본 증거를 대신하지 못한다.
허용되지 않은 심각도를 LOW로 바꾸면 안 되는 이유
검증에서 걸린 값을 어떻게 처리할지가 조용한 취약점이 되는 자리다. 알 수 없는 심각도를 자동으로 LOW로 바꾸는 코드는 정상 동작처럼 보이지만, 검증 실패를 사건 축소 수단으로 쓸 수 있게 만든다. 공격자가 모델 출력에 영향을 줄 수 있다면 스키마를 일부러 깨뜨려 심각도를 떨어뜨릴 수 있다.
알 수 없는 값은 INVALID나 REVIEW로 분리하고 원본 출력을 보존한다. 자동 보정은 값을 채우는 것이 아니라 판정을 만드는 행위라, 어느 방향으로 채우든 근거 없는 판정이 하나 생긴다.
정규식 스캐너가 두 번째 파일에서 못 찾은 것들
bughunt_lab.py는 제공된 vulnerable_sample.py에서 다섯 개 후보를 찾았다.
- 문자열 연결 SQL에 의한 SQL Injection
- 사용자 입력을 셸에 전달하는 Command Injection
- 신뢰할 수 없는 Pickle 역직렬화
- 소스 코드에 포함된 비밀값
- 검증 없이 결합한 파일 경로
같은 스캐너를 extra_vulns_snippet.py에 돌리자 eval, MD5 기반의 결정적 토큰, 보안 용도가 아닌 난수 생성기, tempfile.mktemp 사용을 하나도 잡지 못했다. 첫 파일의 결과만 보면 도구가 코드를 이해하는 것처럼 보이지만, 실제로는 등록된 패턴이 있는 표현만 찾는다. 두 번째 파일이 없었으면 이 도구를 일반적인 AI 코드 분석기로 착각할 수 있었다.
찾아낸 항목들도 검토 방향은 도구가 정해 주지 않는다.
| 위험 패턴 | 검토 방향 |
|---|---|
| SQL 문자열 연결 | 파라미터 바인딩과 쿼리 권한 확인 |
| 셸 명령 조립 | 셸을 거치지 않는 API와 인자 허용 목록 사용 |
| Pickle 역직렬화 | 신뢰 경계 확인, 안전한 직렬화 형식 사용 |
| 하드코딩 비밀값 | 비밀 저장소와 교체·폐기 절차 적용 |
| 경로 결합 | 기준 디렉터리 해석 후 범위 확인 |
eval |
명시적 파서나 허용된 연산으로 교체 |
| 예측 가능한 토큰 | secrets 기반 난수와 만료·일회성 상태 사용 |
| 임시 파일 이름 선점 | NamedTemporaryFile 또는 mkstemp 사용 |
그래서 코드 리뷰 쪽에서 남긴 기준은 다음과 같다. 정규식과 SAST, 의존성 검사, 단위·통합 테스트, 수동 리뷰를 함께 쓴다. 탐지되지 않았다는 결과를 취약점이 없다는 증거로 쓰지 않는다. 발견 항목마다 입력 출처, 위험한 sink, 정제 과정과 실행 권한을 확인한다. 자동 수정은 테스트와 사람의 승인 이후에 적용하고 수정 전후 동작을 비교한다.
실제 모델을 붙이면 채워야 할 기록 항목
지금 구조를 실제 LLM으로 바꿔 달면 추적해야 할 값이 늘어난다. 모의 구현에서는 없거나 항상 같은 값이라 비어 있던 칸들이다.
- 입력, 모델 프롬프트, 모델 버전, 규칙 버전과 출력 원문
- LLM에 넘긴 데이터의 범위 — 판정에 필요한 최소한만 넘기고 비밀값과 개인정보는 제거한다
- 모델이 생성한 심각도와 대응 명령이 실행 전에 통과한 스키마·정책·승인
- 시간 초과, 잘린 JSON, 허용되지 않은 필드, 근거 없는 결론의 처리 결과
- 같은 입력을 반복 실행했을 때의 편차
마지막 항목은 이번 실습에서 잴 수 없었다. 모의 LLM은 정규식이라 같은 입력에 항상 같은 출력을 낸다. 비결정성 측정은 실제 모델을 붙인 다음에야 시작되는 항목이고, 지금 파이프라인이 통과한 테스트가 그 조건에서도 통과한다는 근거는 없다.
직접 돌려서 확인한 범위는 규칙 탐지와 모의 설명, 교차 검증, Jinja2 보고서 생성, 정규식 코드 스캐너 두 파일까지다. 실제 LLM API는 한 번도 호출하지 않았다. 프롬프트 인젝션 방어는 injection_defense_full.py를 읽어 ATTACK_SIG 정규식이 판정을 먼저 잠근다는 구조를 확인한 데까지이고, 다국어 변형이나 간접 인젝션 입력을 새로 만들어 통과하는지 시험해 보지는 않았다. 정상 입력의 오탐률도 재지 않았다.