
Docker lab for reproducing CVE-2025-11262, an unauthenticated stored blind XSS in Link Whisper Free WordPress plugin. Includes vulnerable and patched targets, PoC script, and root cause analysis.
이 저장소는 WordPress 플러그인 Link Whisper Free에 영향을 미치는 인증되지 않은 저장형 크로스 사이트 스크립팅 문제인 CVE-2025-11262를 재현하기 위한 로컬 Docker 랩을 포함합니다.
이 랩은 두 가지 플러그인 버전을 비교합니다:
| 서비스 | 플러그인 버전 | 목적 | URL |
|---|---|---|---|
vuln | 0.9.0 | 취약한 대상 | http://127.0.0.1:8081 |
patched | 0.9.1 | 패치된 비교 대상 | http://127.0.0.1:8082 |
시연된 취약점 체인은 다음과 같습니다:
인증되지 않은 REST 요청
→ 공격자가 제어하는 user_id가 저장됨
→ 권한 있는 WordPress 사용자가 Link Whisper AI Subscription 페이지를 엶
→ 저장된 값이 관리자 JavaScript 컨텍스트에 렌더링됨
→ alert("CVE-2025-11262-LAB")이 취약한 버전에서 실행됨
공격자는 저장된 페이로드를 심기 위해 로그인할 필요가 없습니다. JavaScript는 나중에 권한 있는 WordPress 사용자가 영향을 받는 관리자 페이지를 열 때 실행됩니다.
이 랩은 통제된 로컬 연구, 소스 수준 이해 및 포트폴리오 시연 전용으로 설계되었습니다.
| 주장 | 증거 | 이 랩에서 확인하는 방법 |
|---|---|---|
Link Whisper Free 0.9.0이 취약합니다. | 공개 권고는 Link Whisper Free 버전 0.9.0까지 영향을 받는 것으로 식별합니다. | PoC를 http://127.0.0.1:8081에 대해 실행하고 출력된 관리자 URL을 엽니다. |
Link Whisper Free 0.9.1에는 수정 사항이 포함되어 있습니다. | 공개 권고 및 변경 로그 데이터는 0.9.1을 패치된 버전으로 식별합니다. | 동일한 PoC를 http://127.0.0.1:8082에 대해 실행하면 알림이 나타나지 않아야 합니다. |
| 페이로드 심기는 인증되지 않습니다. | PoC는 WordPress 쿠키, 로그인 또는 nonce 없이 POST 요청을 보냅니다. | poc/poc.py를 검사하면 --url만 필요합니다. |
| 가시적인 영향은 WordPress 관리자 영역에서 트리거됩니다. | 저장된 값은 권한 있는 사용자가 Link Whisper AI Subscription 페이지를 열 때 렌더링됩니다. | PoC 실행 후 admin으로 로그인하고 출력된 관리자 URL을 엽니다. |
패치된 대상은 HTTP 계층에서 여전히 "ok"를 반환할 수 있습니다. | 로컬 테스트에서 두 대상 모두 "ok"를 반환할 수 있습니다. 의미 있는 차이는 페이로드가 저장되고 실행되는지 여부입니다. | 8081 및 8082에서 브라우저 동작을 비교합니다. |
다음은 Root Cause Summary 부분입니다. README에 바로 대체할 수 있습니다. public-safe이며, vuln_detail.txt와 같은 내부 파일을 언급하지 않고 현재 사용 중인 lab/source를 기반으로 합니다.
CVE-2025-11262는 Link Whisper Free 0.9.0에서 저장형 JavaScript 주입 체인으로 인해 발생합니다.
이 문제는 단일 이스케이프 호출 누락이 아닙니다. 여러 안전하지 않은 동작의 체인입니다:
인증되지 않은 REST 엔드포인트
→ user_id의 불충분한 검증
→ wpil_ai_access_user_id에 영구 저장
→ 관리자 JavaScript 컨텍스트로 안전하지 않은 렌더링
→ 권한 있는 사용자가 AI Subscription 페이지를 열 때 저장형 XSS 발생
Link Whisper Free는 플러그인 REST 네임스페이스 아래에 AI 인증 REST 엔드포인트를 등록합니다:
const REST_SLUG = 'link-whisper';
const AI_AUTH = 'ai-auth';
엔드포인트는 POST 경로로 등록됩니다:
register_rest_route(self::REST_SLUG, self::AI_AUTH, [
'methods' => 'POST',
'callback' => [
$this,
'ai_auth_handler'
],
'permission_callback' => "__return_true",
'show_in_index' => false
]);
권한 콜백이 __return_true이므로 엔드포인트는 인증 없이 접근 가능합니다.
랩에서 실제 엔드포인트는 다음과 같습니다:
/wp-json/link-whisper/ai-auth
즉, 인증되지 않은 공격자는 WordPress 세션, nonce 또는 관리자 계정 없이 엔드포인트에 요청을 보낼 수 있습니다.
Link Whisper Free 0.9.0에서 핸들러는 REST 요청에서 공격자가 제어하는 매개변수를 읽습니다:
public function ai_auth_handler( WP_REST_Request $request )
{
if(!empty($request->get_param('access_token'))){
$token = $request->get_param('access_token');
$user_id = $request->get_param('user_id');
$uid = (int)$request->get_param('uid');
$uemail = $request->get_param('uemail');
if(!empty($token) && false !== strpos($token, 'ai-')){
update_option('wpil_ai_access_token', Wpil_Toolbox::encrypt($token));
update_option('wpil_ai_access_user_id', $user_id);
update_option('wpil_ai_access_user_email', $uemail);
update_user_meta($uid, 'wpil_ai_access_user_id', $user_id);
update_user_meta($uid, 'wpil_ai_access_user_email', $uemail);
update_option('wpil_ai_access_authorized', true);
}
return 'ok';
}
return new WP_Error(400, 'Bad request', [ 'status' => 404 ]);
}
취약한 동작은 약한 검증 조건입니다:
if(!empty($token) && false !== strpos($token, 'ai-')){
이것은 제공된 액세스 토큰에 문자열 ai-가 포함되어 있는지만 확인합니다.
user_id는 저장되기 전에 엄격한 검증이 없습니다:
update_option('wpil_ai_access_user_id', $user_id);
결과적으로 공격자가 제어하는 JavaScript가 WordPress 옵션 테이블에 저장될 수 있습니다.
공격자가 제어하는 user_id 값은 WordPress 옵션에 저장됩니다:
wpil_ai_access_user_id
이 랩에서 PoC는 다음 로컬 전용 페이로드를 보냅니다:
</script><script>alert("CVE-2025-11262-LAB")</script>
취약한 서비스에서 페이로드는 wpil_ai_access_user_id의 값으로 저장됩니다.
공격자는 페이로드를 심기 위해 로그인할 필요가 없습니다. 페이로드는 인증되지 않은 REST 엔드포인트를 통해 심어집니다.
저장된 값은 나중에 플러그인 설정 로직을 통해 검색됩니다:
public static function get_linkwhisper_ai_user_id(){
return get_option('wpil_ai_access_user_id', '');
}
값은 $ai_id에 할당되고 AI Subscription 관리자 페이지로 렌더링됩니다.
Link Whisper Free 0.9.0에서 값은 JavaScript 문자열에 직접 삽입됩니다:
body: JSON.stringify({
ai_id: "<?php echo $ai_id;?>",
subscription_id: "<?php echo ((!empty($sub)) && isset($sub->subscription_id)) ? $sub->subscription_id: null;?>"
})
$ai_id가 JavaScript 컨텍스트에 삽입되기 전에 이스케이프되지 않으므로 저장된 페이로드가 의도된 문자열을 벗어나 관리자 페이지가 열릴 때 JavaScript를 실행할 수 있습니다.
랩 페이로드를 사용하면 취약한 렌더링 출력은 다음과 동등해집니다:
body: JSON.stringify({
ai_id: "</script><script>alert("CVE-2025-11262-LAB")</script>",
subscription_id: ""
})
브라우저에서 주입된 닫는 </script> 태그가 원래 스크립트 블록을 종료하고 주입된 <script> 블록이 실행됩니다.
페이로드는 인증되지 않은 공격자에 의해 심어지지만, 실행은 권한 있는 WordPress 사용자가 영향을 받는 관리자 페이지를 열어야 합니다:
/wp-admin/admin.php?page=link_whisper_ai_subscription
이 랩에서 영향을 받는 페이지는 WordPress 관리자로 열려 경고 대화상자를 트리거합니다.
따라서 이 문제는 Link Whisper AI Subscription 관리자 페이지에 접근할 수 있는 인증된 WordPress 관리자 또는 권한 있는 사용자를 대상으로 하는 인증되지 않은 저장형 XSS입니다.
Link Whisper Free 0.9.1은 AI 인증 값을 저장하기 전에 더 엄격한 검증을 추가합니다.
패치된 핸들러는 토큰과 사용자 ID가 엄격한 형식과 일치해야 합니다:
if(
!empty($token) &&
false !== strpos($token, 'ai-') &&
(bool) preg_match('/\Aai-[0-9a-f]{64}\z/i', $token) &&
(bool) preg_match('/\A[0-9a-f]{32}\z/i', $user_id)
){
update_option('wpil_ai_access_token', Wpil_Toolbox::encrypt($token));
update_option('wpil_ai_access_user_id', $user_id);
update_option('wpil_ai_access_user_email', sanitize_email($uemail));
update_option('wpil_ai_access_authorized', true);
}
user_id에 추가된 중요한 검증은:
preg_match('/\A[0-9a-f]{32}\z/i', $user_id)
이것은 임의의 JavaScript가 AI 사용자 ID로 저장되는 것을 방지합니다.
버전 0.9.1은 또한 JavaScript 컨텍스트로 렌더링하기 전에 값을 이스케이프합니다:
body: JSON.stringify({
ai_id: "<?php echo esc_attr($ai_id);?>",
subscription_id: "<?php echo ((!empty($sub)) && isset($sub->subscription_id)) ? esc_attr($sub->subscription_id): '';?>"
})
따라서 패치는 두 지점에서 문제를 완화합니다:
입력 검증 (저장 전)
출력 이스케이프 (JavaScript 렌더링 전)
랩은 0.9.0과 0.9.1의 차이를 확인합니다.
Link Whisper Free 0.9.0에서:
POST /wp-json/link-whisper/ai-auth
→ "ok" 반환
→ 페이로드를 wpil_ai_access_user_id에 저장
→ AI Subscription 관리자 페이지를 열면 alert("CVE-2025-11262-LAB") 트리거
Link Whisper Free 0.9.1에서:
POST /wp-json/link-whisper/ai-auth
→ 여전히 "ok"를 반환할 수 있음
→ 페이로드를 저장하지 않음
→ AI Subscription 관리자 페이지를 열어도 알림이 트리거되지 않음
HTTP 응답만으로는 대상이 취약한지 판단하기에 충분하지 않습니다. 두 버전 모두 "ok"를 반환할 수 있기 때문입니다. 의미 있는 동작 차이는 페이로드가 저장되고 나중에 관리자 JavaScript 컨텍스트에서 렌더링되는지 여부입니다.
랩은 Docker Compose를 통해 두 개의 격리된 WordPress 인스턴스와 두 개의 별도 MySQL 데이터베이스를 실행합니다.
.
├── docker/
│ └── lab-entrypoint.sh
├── docker-compose.yml
├── patched/
│ └── Dockerfile
├── poc/
│ └── poc.py
├── README.md
└── vuln/
└── Dockerfile
Docker 엔트리포인트는 자동으로:
두 서비스의 기본 WordPress 관리자 자격 증명:
admin / AdminPassw0rd!
랩 빌드 및 시작:
docker compose down -v
docker compose build --no-cache
docker compose up -d
컨테이너 확인:
docker compose ps
예상 노출 서비스:
취약 대상: http://127.0.0.1:8081
패치 대상: http://127.0.0.1:8082
설정 로그도 확인할 수 있습니다:
docker compose logs vuln patched
성공적인 설정은 각 WordPress 인스턴스에서 Link Whisper가 활성화된 것을 보여줍니다.
취약한 서비스에 대해 PoC 실행: