又是项目中的一个,从中get一个cve,github有800+的star,还算人在用,也有一定用户量,CVE-2017-12775
目前最新是1.7.5,审计的是1.7.4
分享一下分析过程(不涉及漏洞),总结一下写法以及思维。
比较小巧的一个程序,功能也是比较少,单纯的为了实现一下q&a吧
先观察目录
比较直观,可以看到核心东西都放在qa-include里面
接手程序,按顺序先分析,路由 -> 数据库操作 -> 具体危害函数
路由分析
先进入index.php
qa-include/qa-index.php
if (isset($_POST['qa']) && $_POST['qa'] == 'ajax')
require 'qa-ajax.php';
elseif (isset($_GET['qa']) && $_GET['qa'] == 'image')
require 'qa-image.php';
elseif (isset($_GET['qa']) && $_GET['qa'] == 'blob')
require 'qa-blob.php';
else {
xxx
}
有一些特别的操作,跟进一个
qa-include/qa-ajax.php
require 'qa-base.php';
qa_report_process_stage('init_ajax');
qa_set_request(qa_post_text('qa_request'), qa_post_text('qa_root'));
$_GET=array(); // for qa_self_html()
function qa_ajax_db_fail_handler(){
echo "QA_AJAX_RESPONSE\n0\nA database error occurred.";
qa_exit('error');
}
$routing=array(
'notice' => 'notice.php',
'favorite' => 'favorite.php',
'vote' => 'vote.php',
'recalc' => 'recalc.php',
'mailing' => 'mailing.php',
'version' => 'version.php',
'category' => 'category.php',
'asktitle' => 'asktitle.php',
'answer' => 'answer.php',
'comment' => 'comment.php',
'click_a' => 'click-answer.php',
'click_c' => 'click-comment.php',
'click_admin' => 'click-admin.php',
'show_cs' => 'show-comments.php',
'wallpost' => 'wallpost.php',
'click_wall' => 'click-wall.php',
'click_pm' => 'click-pm.php',
);
$operation=qa_post_text('qa_operation');
if (isset($routing[$operation])) {
qa_db_connect('qa_ajax_db_fail_handler');
require QA_INCLUDE_DIR.'ajax/'.$routing[$operation];
qa_db_disconnect();
}
qa-base.php
里面就是一些程序初始化,全是函数,可以跳过。
1、参数接收
qa_set_request(qa_post_text('qa_request'), qa_post_text('qa_root'));
很显眼的是接受GET
、POST
参数用的,跟进一下qa_post_text
函数
function qa_post_text($field){
if (qa_to_override(__FUNCTION__)) { $args=func_get_args(); return qa_call_override(__FUNCTION__, $args); }
return isset($_POST[$field]) ? preg_replace('/\r\n?/', "\n", trim(qa_gpc_to_string($_POST[$field]))) : null;
}
这个支持函数覆盖,但是一般默认是没有的,所以前面的qa_to_override
也是利用不上$_POST
数组先经过qa_gpc_to_string
处理,这个是会针对GPC
,把转义字符去掉
function qa_gpc_to_string($string){
return get_magic_quotes_gpc() ? stripslashes($string) : $string;
}
可以看到后面是会把$_POST
数组中的一些\r\n
字符给替换为\n
get或者其他类似
2、路由分配
返回qa-include/qa-ajax.php
接着继续跟
它的路由都是硬编码,而且是限定在数组里面,里面很多进入require
、include
都进入这样限定的数组去操作,能一定程度避免问题
ajax.php
就到此为止,现在回到qa-include/qa-index.php
的条件判断里面,最后else
中,这个才是其他操作的路由
先看看怎么样要进入其他的操作,比如install
$requestlower = strtolower(qa_request());
if ($requestlower == 'install')
require QA_INCLUDE_DIR.'qa-install.php';
elseif ($requestlower == 'url/test/'.QA_URL_TEST_STRING)
require QA_INCLUDE_DIR.'qa-url-test.php';
else {
xxx
}
通过qa_request
函数去获取
function qa_request(){
global $qa_request;
return $qa_request;
}
所以,$requestlower
就是全局变量$qa_request
经过小写化
跟踪一下,全局变量$qa_request
是在哪有赋值
qa-include/qa-base.php
function qa_set_request($request, $relativeroot, $usedformat=null) {
global $qa_request, $qa_root_url_relative, $qa_used_url_format;
$qa_request=$request;
$qa_root_url_relative=$relativeroot;
$qa_used_url_format=$usedformat;
}
那就再看下调用的地方
qa-include/qa-index.php
function qa_index_set_request(){
$relativedepth = 0;
if (isset($_GET['qa-rewrite'])) {
/* URLs rewritten by .htaccess or Nginx */
}
elseif (isset($_GET['qa'])) {
if (strpos($_GET['qa'], '/') === false) {
$urlformat = ( empty($_SERVER['REQUEST_URI']) || strpos($_SERVER['REQUEST_URI'], '/index.php') !== false )
? QA_URL_FORMAT_SAFEST : QA_URL_FORMAT_PARAMS;
$requestparts = array(qa_gpc_to_string($_GET['qa']));
for ($part = 1; $part < 10; $part++) {
if (isset($_GET['qa_'.$part])) {
$requestparts[] = qa_gpc_to_string($_GET['qa_'.$part]);
unset($_GET['qa_'.$part]);
}
}
}
else {
$urlformat = QA_URL_FORMAT_PARAM;
$requestparts = explode('/', qa_gpc_to_string($_GET['qa']));
}
unset($_GET['qa']);
}
else {
/* index.php/aaa/bbb */
}
foreach ($requestparts as $part => $requestpart) { // remove any blank parts
if (!strlen($requestpart))
unset($requestparts[$part]);
}
reset($requestparts);
$key = key($requestparts);
$requestkey = isset($requestparts[$key]) ? $requestparts[$key] : '';
$replacement = array_search($requestkey, qa_get_request_map());
if ($replacement !== false)
$requestparts[$key] = $replacement;
qa_set_request(
implode('/', $requestparts),
($relativedepth > 1 ? str_repeat('../', $relativedepth - 1) : './'),
$urlformat
);
}
最后的进入qa_set_request
函数
qa_index_set_request
这个函数实现的功能大概有这三种
第一个是url重写的匹配,对nginx、apache的差异做了变化
第二个是常见的匹配,index.php?qa=xxx
第三个是self模式匹配,index.php/aaa/bbb
数据库操作
以这个为例
qa-include/db/admin.php
function qa_db_category_rename($categoryid, $title, $tags){
qa_db_query_sub(
'UPDATE ^categories SET title=$, tags=$ WHERE categoryid=#',
$title, $tags, $categoryid
);
qa_db_categories_recalc_backpaths($categoryid);
}
可以看到有一些字符,^
、$
、#
重点是qa_db_query_sub
函数
function qa_db_query_sub($query){
$funcargs=func_get_args();
return qa_db_query_raw(qa_db_apply_sub($query, array_slice($funcargs, 1)));
}
继续跟进
function qa_db_apply_sub($query, $arguments)
{
$query = preg_replace_callback('/\^([A-Za-z_0-9]+)/', 'qa_db_prefix_callback', $query);
if (!is_array($arguments))
return $query;
$countargs = count($arguments);
$offset = 0;
for ($argument = 0; $argument < $countargs; $argument++) {
$stringpos = strpos($query, '$', $offset);
$numberpos = strpos($query, '#', $offset);
if ($stringpos === false || ($numberpos !== false && $numberpos < $stringpos)) {
$alwaysquote = false;
$position = $numberpos;
}
else {
$alwaysquote = true;
$position = $stringpos;
}
if (!is_numeric($position))
qa_fatal_error('Insufficient parameters in query: '.$query);
$value = qa_db_argument_to_mysql($arguments[$argument], $alwaysquote);
$query = substr_replace($query, $value, $position, 1);
$offset = $position + strlen($value); // allows inserting strings which contain #/$ character
}
return $query;
}
preg_replace_callback
的调用将^
替换为表前缀
后面就是在查找$
、#
的位置
再进入qa_db_argument_to_mysql
看看最后是怎么样操作的
function qa_db_argument_to_mysql($argument, $alwaysquote, $arraybrackets=false)
{
if (is_array($argument)) {
$parts=array();
foreach ($argument as $subargument)
$parts[] = qa_db_argument_to_mysql($subargument, $alwaysquote, true);
if ($arraybrackets)
$result = '('.implode(',', $parts).')';
else
$result = implode(',', $parts);
}
elseif (isset($argument)) {
if ($alwaysquote || !is_numeric($argument))
$result = "'".qa_db_escape_string($argument)."'";
else
$result = qa_db_escape_string($argument);
}
else
$result = 'NULL';
return $result;
}
可以看到
if ($alwaysquote || !is_numeric($argument))
$result = "'".qa_db_escape_string($argument)."'";
else
$result = qa_db_escape_string($argument);
如果是$
占位的话,表示是字符串,这时候会加上单引号,然后再经过qa_db_escape_string处理,也就是real_escape_string的处理
如果是#
占位的话,表示是数字,但是,如果#
占位也有非数字出现,也是会进行单引号里面。
这种写法,也就只能找两个地方的点
1、直接去查询qa_db_query_raw
函数(直接调用mysql的query查询)
2、变量拼接进入sql
很可惜还没找到这个利用点