question2answer之旅

2017-08-14 oma15g0550914

又是项目中的一个,从中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_RESPONSEn0nA 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'));

很显眼的是接受 GETPOST 参数用的,跟进一下 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('/rn?/', "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 数组中的一些 rn 字符给替换为 n
get或者其他类似

2、路由分配

返回 qa-include/qa-ajax.php 接着继续跟

它的路由都是硬编码,而且是限定在数组里面,里面很多进入 requireinclude 都进入这样限定的数组去操作,能一定程度避免问题

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

很可惜还没找到这个利用点


用户评论
开源开发学习小组列表