AI가 작성한 60개의 WordPress 플러그인, 그리고 우연히 안전해진 JavaScript 이스케이프 (escaping)
요약
AI가 생성한 WordPress 플러그인 코드의 보안 취약점을 분석합니다. 단순히 이스케이프 함수를 사용하는 것을 넘어, 데이터가 놓이는 컨텍스트에 맞는 적절한 함수(예: esc_html vs esc_url)를 선택하는 것이 중요함을 강조합니다.
핵심 포인트
- AI는 이스케이프 함수를 사용하더라도 잘못된 컨텍스트를 선택할 수 있음
- 데이터가 위치한 HTML 컨텍스트에 따라 적절한 이스케이퍼 선택 필수
- esc_html()은 URL 스킴 공격(javascript:)을 방어하지 못함
- WordPress 보안 가이드라인과 실제 함수 범위 간의 불일치 주의
이 포스트는 두 부분으로 나뉩니다. 뻔한 정답이 더 이상 통하지 않을 때 "출력값을 이스케이프(escape your output)하라"는 말이 실제로 무엇을 의미하는지, 그리고 현재의 AI 어시스턴트들이 이 더 어려운 버전을 제대로 수행하는지에 대한 연구입니다. 이 글은 비전문가가 코딩 어시스턴트에게 WordPress 코드를 요청했을 때 실제로 무엇을 생성하는지에 대한 시리즈의 연장선입니다.
검토를 통과하면서도 여전히 취약점을 남기는 코드 한 줄로 시작해 보겠습니다:
echo '<a href="' . esc_html( $url ) . '">Visit</a>';
값 주위에 이스케이프 함수가 감싸져 있습니다. grep으로 esc_를 검색하면 찾을 수 있습니다. 빠른 검토로는 통과됩니다.
이제 $url을 javascript:alert(document.cookie)로 설정해 보겠습니다. 방문자가 링크를 클릭하면 여전히 실행됩니다. esc_html()은 <나 >와 같이 HTML 텍스트에서 중요한 문자를 이스케이프(escape)합니다. 하지만 위험한 URL 스킴(scheme)에 대해서는 아무런 조치도 취하지 않습니다.
코드는 이스케이프되었습니다. 하지만 잘못된 컨텍스트(context)를 위해 이스케이프되었습니다. 외부에서 보기에는 올바른 이스케이프와 잘못된 컨텍스트의 이스케이프가 동일해 보입니다.
URL은 다른 컨텍스트이며 자체적인 이스케이퍼(escaper)가 필요합니다. 그 이스케이퍼는 esc_url()이며, javascript: URL에 대해 빈 문자열을 반환합니다.
어떤 이스케이퍼를 어디에 사용할 것인가
이스케이핑(Escaping)은 컨텍스트에 따라 달라집니다. "입력 시 정화(Sanitize on input)하고, 출력 시 이스케이프(escape on output)하라"는 규칙은 이 시리즈의 첫 번째 포스트에서 다루었지만, "출력 시 이스케이프"라는 말은 두 번째 결정 사항, 즉 "어떤 이스케이퍼를 사용할 것인가"를 숨기고 있습니다. 정답은 값이 페이지의 어느 위치에 놓이느냐에 달려 있습니다.
| 값이 놓이는 위치 |
|---|
태그 사이의 가시적인 텍스트 (<p>HERE</p>) |
| ... |
여섯 개의 함수가 각각 하나의 작업을 수행합니다. 실수는 거의 항상 "이스케이프를 잊어버린 것"이 아니라 "잘못된 컨텍스트를 위해 이스케이프한 것"이며, 위의 URL에 사용된 esc_html()이 가장 깔끔한 예시입니다. 함수와 그 컨텍스트는 WordPress 자체의 것이며, 이 표가 추가하는 것은 오직 값이 놓이는 위치에 따라 선택하는 습관뿐입니다.
한 행은 경고가 필요합니다. 왜냐하면 WordPress 자체의 가이드라인은 다르게 설명하고 있기 때문입니다. 해당 페이지는 <script> 블록 내부에서 esc_js()를 사용하는 것을 보여줍니다. 하지만 이 함수에 대한 Core 자체의 문서는 onclick="..."를 예시로 들며, 함수의 범위를 태그 속성(attribute)으로 제한합니다. 이 두 가지는 서로 일치하지 않으며, 이 포스트의 후반부는 왜 이것이 중요한지에 대한 내용을 일부 다룹니다.
단순한 컨텍스트(contexts)는 이번 시리즈에서 실수가 발견된 지점이 아닙니다. 이전 실험에서는 URL 케이스를 테스트했습니다. 단순히 "링크를 보여달라"는 작업이 주어졌을 때, 8번의 실행 중 8번 모두가 요청하지 않았음에도 esc_url()을 사용했습니다. 따라서 연구는 진정으로 미묘한 지점으로 나아가야 했습니다. 그 지점이 바로 JavaScript입니다.
JavaScript가 까다로운 이유
인라인 스크립트(inline script)에 값을 넣는 것은 흔히 하는 일입니다:
echo '<script>var status = "' . $value . '";</script>';
만약 $value에 </script>가 포함되어 있다면, 브라우저는 당신이 그것을 JavaScript 문자열 내부의 텍스트로 의도했다는 사실을 신경 쓰지 않습니다. HTML 파서(parser)는 <script> 요소의 원시 콘텐츠를 스캔하여 닫는 태그를 찾고, 이를 발견하면 태그를 조기에 닫아버린 뒤 그 뒤에 오는 모든 것을 새로운 HTML로 파싱합니다. 이것이 바로 **브레이크아웃(breakout)**입니다. 값이 지정된 슬롯을 탈출하여 마크업(markup)이 되어버리는 것입니다.
파서는 단순한 형태보다 조금 더 일반적입니다. 대소문자를 구분하지 않고 </script를 찾으며, 그 뒤에 공백, / 또는 >가 오는 경우를 찾기 때문에 </SCRIPT >도 태그를 닫게 됩니다. 단순히 소문자 문자열을 무력화하는 것만으로는 충분하지 않습니다.
이것이 HTML 명세(specification)가 스크립트 파싱을 정의하는 방식이며, 그중 한 가지 세부 사항이 아래의 모든 내용을 결정합니다: 스크립트 내부에서는 문자 참조(character references)가 디코딩되지 않습니다. <는 문자 그대로의 텍스트 <로 남습니다. 결코 <로 다시 변환되지 않습니다.
그 사실이 안전한 접근 방식과 안전하지 않은 접근 방식을 가릅니다. 아래 진리표(truth table)의 모든 행은 WordPress 코어, PHP 매뉴얼, 그리고 페이로드(payload)를 실제 함수를 통해 실행하고 바이트를 출력하는 조사 스크립트 (probe script)를 통해 검증되었습니다.
| 코드의 동작 | 브라우저가 수신하는 내용 | 탈출(Breaks out) 여부? |
|---|---|---|
| 이스케이프 (escaping) 없는 원시 연결 (Raw concatenation) | </script>... | 예 |
| ... | ||
| _(PHP에서 플래그는 비트 연산자 ( | )와 결합됩니다. +는 표의 가독성을 위한 것이며, 이 플래그들의 값은 동일합니다.)_ |
두 개의 안전한 중간 행이 이번 연구의 핵심이며, 이들은 서로 다른 이유로 안전합니다.
esc_js()는 탈출을 차단하지만 값을 손상시킵니다. 이 함수는 <와 >를 <와 >로 엔티티 이스케이프 (entity-escapes) 처리하며, 이는 스크립트 내부에서 비활성 상태(inert)가 됩니다. 하지만 동일한 규칙에 따라 해당 엔티티들은 그곳에서 결코 디코딩되지 않으므로, JavaScript 문자열은 방문자가 입력한 문자 대신 문자 그대로의 텍스트 <를 보유하게 됩니다. &와 "에 대해서도 동일한 현상이 발생합니다. 이 함수는 브라우저가 엔티티를 디코딩하는 onclick="..."와 같은 이벤트 속성(event attribute)을 위해 만들어졌습니다. <script> 내부에서는 안전하지만, 조용히 잘못된 결과를 낳습니다.
기본 wp_json_encode()는 부수적인 피해 없이 탈출을 차단합니다. 이 함수는 슬래시(/)를 이스케이프 처리하여 </script>가 </script>가 되도록 만듭니다. 이렇게 되면 파서(parser)가 찾는 종료 시퀀스(closing sequence)를 더 이상 포함하지 않게 됩니다. 값 자체는 온전하게 유지됩니다.
이제 네 번째 행입니다. 개발자는 합리적인 이유로 JSON_UNESCAPED_SLASHES를 추가합니다. 이 플래그가 없으면 출력되는 모든 URL이 http://example.com과 같이 읽혀 깨진 것처럼 보이기 때문입니다. 개발자의 관점에서 이 플래그는 가독성에 관한 것이며 보안과는 아무런 관련이 없습니다. 하지만 이 플래그는 탈출을 막아주던 유일한 수단이었던 슬래시 이스케이프 (slash-escaping)를 제거하며, 결과적으로 탈출이 다시 가능해집니다.
오직 마지막 행만이 의도적으로 안전합니다. JSON_HEX_TAG는 <와 > 자체를 \u003C와 \u003E로 변환하므로, 보호 기능이 슬래시 (slash)에 의존하지 않으며 옆에 어떤 플래그가 쌓이더라도 유지됩니다. 이것이 WordPress 코어에서 인라인 JSON을 출력할 때 사용하는 방식입니다. script-modules API가 이를 사용하며 그 이유를 코드 주석으로 명시하고 있고, wp_localize_script는 WordPress 6.9부터 이를 사용해 왔습니다.
따라서 우연히 안전해지는 데에는 두 가지 방법이 있으며, 이들은 서로 다르게 실패합니다:
| 메커니즘 | JSON_UNESCAPED_SLASHES를 견디는가? | 값이 온전한가? |
|---|---|---|
기본 wp_json_encode() | 아니요 | 예 |
| ... |
앞의 두 가지 중 어느 것도 이 용도(slot)를 위해 구축된 방어책이 아닙니다. 이것이 여기서 말하는 "우연히"의 의미입니다. 즉, 보호 기능이 목적이 아니라 부수 효과 (side effect)라는 것입니다. 아래 연구의 5회 실행 중 일부는 </script>를 중화한다는 점을 알고 wp_json_encode를 선택했습니다. 그 5회 중 3회는 더 나아가 esc_js가 해당 용도에 잘못된 도구라고 지목했습니다. "우연히 안전한 것"과 "설계에 의해 안전한 것" 사이의 차이가 바로 이 연구가 어시스턴트들에게 던지는 질문입니다.
연구
질문. 비전문가가 어시스턴트에게 WordPress 페이지의 JavaScript에 값을 넣도록 요청했을 때, 생성된 코드가 </script> 탈출 (breakout)에 대해 안전한가? 만약 그렇다면, 그것은 설계에 의해 안전한 것인가 아니면 우연히 안전한 것인가?
실행 전 확정. 이 연구는 시리즈 중 처음으로, 실행 전 예측을 작성하여 공개 저장소에 푸시한 연구이므로 타임라인 확인이 가능합니다. 예측 파일은 5개의 호출을 수행했으며, 점수는 이 포스트의 마지막에 공개되었습니다.
설계. 세 명의 어시스턴트를 각각 클린 룸 (clean room) 환경에서 테스트했습니다. 사용자 맞춤 설정은 제거되었으며, 코드는 디스크에 기록되는 대신 터미널에 출력되었습니다. 그중 한 명은 다른 이들보다 더 높은 추론 노력 (reasoning effort)으로 실행되었는데, 이는 모델이 답변하기 전에 더 많은 시간을 생각할 수 있게 해주는 설정입니다.
| Assistant | 버전 및 모델 |
|---|---|
| Claude Code | 2.1.207, claude-opus-4-8 |
| ... | |
| 각 어시스턴트(Assistant)는 단순한 모델이 아닌 하나의 제품입니다. 즉, 모델에 숨겨진 하네스(harness)가 결합된 형태이며, 이는 외부에서 제거할 수 없는 벤더(vendor) 고유의 시스템 프롬프트(system prompt)와 툴링(tooling)을 의미합니다. 세 가지 서로 다른 제품 간에 조건을 완전히 일치시키는 것은 불가능했습니다. 아래 결과에서 중요한 점은 다음과 같습니다: Codex는 가장 높은 추론 노력 (reasoning effort)으로 실행된 반면, 나머지 두 모델은 기본 설정값으로 실행되었습니다. 연구의 파라미터 테이블 (parameter table)에 나머지 정보가 나열되어 있습니다. |
보안이나 이스케이프 (escaping)를 전혀 언급하지 않고, 단순한 기능 요청으로 작성된 난이도가 점진적으로 높아지는 세 가지 작업입니다:
- (a) popup — 방문자가 입력한 이름으로 방문자에게 인사합니다. 어시스턴트당 4회 실행.
- (b) slideshow — 선택 사항으로 웹사이트 링크를 포함한 방문자 메시지를
첫 번째 발견은 예측하지 못했던 것이었으며, 가장 흥미로운 부분이었습니다. 두 가지 더 쉬운 작업에서, 어시스턴트들은 위험한 컨텍스트 (context)를 완전히 피했습니다.
36번의 팝업 (popup) 및 슬라이드쇼 (slideshow) 실행 전체를 통틀어, 단 하나의 어시스턴트도 PHP에서 <script> 블록 내에 방문자의 값을 출력하지 않았습니다. 팝업은 이름을 완전히 브라우저 내에서 처리했으며 서버로 전송하지 않았습니다. 슬라이드쇼는 모든 메시지를 일반적인 서버 측 HTML로 렌더링했으며, JavaScript는 미리 구축된 카드들을 이동시키는 용도로만 사용했습니다. 24번의 슬라이드쇼 실행 중 23번은 해당 컨텍스트에 맞춰 메시지를 이스케이프 (escape) 처리했습니다. 한 번은 이스케이퍼 (escaper) 대신 허용 목록 (allow-list) 방식인 wp_kses_post()를 통해 필터링했는데, 이는 입력값 정화기 (input sanitizer)에 의해 수행되었습니다. 이는 본 시리즈에서 계속 경고해 온 대체 (substitution) 현상의 생생한 사례입니다.
36번의 실행 중 7번에서 PHP가 <script> 요소에 값을 출력했을 때, 플러그인 자체가 생성한 값들만 출력했습니다: DOM id (페이지 상의 요소 식별자), 슬라이드 간격 (slide interval), 저장 키 상수 (storage-key constant), 최대 길이 (maximum length) 등이었습니다. 이 값들은 모두 이스케이퍼 (escaper)를 거치거나 정수 형변환 (integer cast)을 거쳤습니다.
가장 강력하게 나타난 방어 기제는 영리한 이스케이퍼 (escaper)가 아니었습니다. 그것은 바로 아키텍처 (architecture)였습니다. 즉, 값이 위험해질 수 있는 컨텍스트 (context) 밖에 두는 것이었습니다.
값을 PHP 외부로 유지한다고 해서 그것이 무해해지는 것은 아닙니다. 일단 이름이 브라우저에 존재하게 되면, JavaScript에서 동일한 질문이 다시 제기됩니다: 이 값이 어떤 컨텍스트 (context)에 놓이게 되는가 하는 점입니다. textContent를 사용하여 작성하면 값은 비활성 (inert) 상태로 남지만, innerHTML을 사용하여 작성하면 HTML 파서 (parser)에게 제어권을 넘겨주게 됩니다.
12번의 팝업 실행 중 10번은 브라우저가 마크업 (markup)이 아닌 텍스트로 취급하는 비활성 싱크 (inert sink)를 사용했습니다. 나머지 2번은 그렇지 않았습니다. 한 번은 꺾쇠괄호 (angle brackets)를 수동으로 이스케이프 처리한 후 어쨌든 innerHTML을 사용했는데, 이는 작동은 하지만 브라우저가 파싱하는 싱크 (sink) 앞에 수제 이스케이퍼 (escaper)를 두는 방식입니다. 다른 한 번은 가공되지 않은 이름 (raw name)을 innerHTML에 직접 연결 (concatenate)했는데, 이는 페이로드 (payload)를 실행할 수 있습니다. 정확성에 관한 글인 만큼 명시할 가치가 있는 점은, 주입된 <script> 태그는 innerHTML을 통해 설정될 때 실행되지 않지만, <img src=x onerror=...>와 같은 페이로드는 실행된다는 것입니다.
영향 범위는 좁습니다. 해당 이름은 방문자 자신의 브라우저에만 머물며 다른 누구에게도 보여지지 않으므로, 공격받을 수 있는 유일한 대상은 그것을 입력한 본인뿐입니다. 이는 </script> 문제에 비하면 규모가 작지만, 동일한 교훈을 줍니다. 즉, 값의 안전성은 그것이 마지막으로 배치된 위치에 따라 결정된다는 것입니다.
세 가지 컨텍스트(three-context) 작업에서는 JavaScript 슬롯을 피할 수 없습니다. 이 작업은 console.log를 명시적으로 호출하므로, 값이 반드시 JavaScript에 도달해야 합니다. 24번의 실행 결과는 다음과 같습니다:
| Assistant | 기본 wp_json_encode | esc_js | JSON_HEX_TAG |
|---|---|---|---|
| Claude Code | 8개 중 7개 | 8개 중 1개 | 8개 중 0개 |
| ... |
24개 모두 탈출(breakout) 공격으로부터 안전했습니다. 단 하나도 원시 연결(raw concatenation)을 사용하지 않았습니다. 단 하나도 JSON_UNESCAPED_SLASHES를 추가하지 않았습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기