SQLite可被利用的字符串漏洞:CVE-2022-35737深度解析

本文详细分析了SQLite库中的CVE-2022-35737漏洞,该漏洞影响使用printf函数的SQLite API,可通过大字符串输入导致整数溢出和栈缓冲区溢出,进而实现任意代码执行或拒绝服务攻击。

Stranger Strings: SQLite中的一个可被利用的漏洞

背景:偶然发现一个漏洞

最近一篇博客文章介绍了PHP中的一个漏洞,这似乎是进行变体分析的完美候选。该漏洞在64位无符号整数字符串长度被隐式转换为32位有符号整数时出现。我们针对此漏洞类进行了变体分析,发现了一些漏洞,其中大多数较为普通,但有一个特别突出:PHP PDO SQLite模块中用于正确转义引号字符的函数。这开始了我们对SQLite字符串格式化的奇怪旅程。

SQLite是最广泛部署的数据库引擎,部分归功于其非常宽松的许可和跨平台、可移植的设计。它用C语言编写,可以编译成独立应用程序或库,供应用程序程序员使用。它似乎无处不在——当我们在其他地方寻找漏洞时偶然发现这个漏洞,这一看法得到了加强。

1
2
3
4
5
6
7
8
9
static zend_string* sqlite_handle_quoter(pdo_dbh_t *dbh, const zend_string *unquoted, enum pdo_param_type paramtype)
{
    char *quoted = safe_emalloc(2, ZSTR_LEN(unquoted), 3);
    /* TODO use %Q format? */
    sqlite3_snprintf(2*ZSTR_LEN(unquoted) + 3, quoted, "'%q'", ZSTR_VAL(unquoted));
    zend_string *quoted_str = zend_string_init(quoted, strlen(quoted), 0);
    efree(quoted);
    return quoted_str;
}

在第231行,一个无符号长整型(2*ZSTR_LEN(unquoted) + 3)作为第一个参数传递给sqlite3_snprintf,该函数期望一个有符号整数。这令人兴奋,我们很快编写了一个简单的概念验证。我们期望能够利用此漏洞,通过将大字符串传递给函数,产生格式错误的字符串,引号字符不匹配,并可能在易受攻击的应用程序中实现SQL注入。

想象一下,当我们的概念验证使PHP解释器崩溃时,我们有多惊讶:我们的漏洞中还有一个漏洞!

我们很快确定崩溃发生在SQLite共享对象中,因此我们自然更仔细地查看了sqlite3_snprintf函数。

SQLite实现了自定义版本的printf系列函数,并添加了新的格式说明符%Q、%q和%w,这些说明符设计用于正确转义输入字符串中的引号字符,以生成安全的SQL查询。例如,我们编写了以下代码片段,正确使用sqlite3_snprintf和格式说明符%q,输出一个字符串,其中所有单引号字符都用另一个单引号转义。此外,整个字符串用前导和尾随单引号包裹,正如PHP引用函数所意图的那样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sqlite3.h>

int main(int argc, char *argv[]) {
    char src[] = "hello, \'world\'!";
    char dst[sizeof(src) + 4];  // Add 4 to account for extra quotes.

    sqlite3_snprintf(sizeof(dst), dst, "'%q'", src);

    printf("src: %s\n", src);
    printf("dst: %s\n", dst);
    return 0;
}

sqlite3_snprintf正确地将原始字符串包裹在单引号中,并转义输入字符串中任何现有的单引号。

接下来,我们更改程序以模仿PHP脚本的行为,直接将相同的大2GB字符串传递给sqlite3_snprintf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sqlite3.h>

#define STR_LEN ((0x100000001 - 3) / 2)

int main(int argc, char *argv[]) {
    char *src = calloc(1, STR_LEN + 1); // Account for NULL byte.
    memset(src, 'a', STR_SIZE);
    char *dst = calloc(1, STR_LEN + 3); // Account for extra quotes and NULL byte.

    sqlite3_snprintf(2*STR_LEN + 3, dst, "'%q'", src);

    printf("src: %s\n", src);
    printf("dst: %s\n", dst);
    return 0;
}

崩溃!我们似乎找到了罪魁祸首:对sqlite3_snprintf的大输入。这开始了一段深入研究的旅程,我们发现SQLite在其自定义printf系列函数的部分中没有正确处理大字符串。更深入的是,我们发现编译器优化使利用SQLite漏洞更容易。

漏洞

自定义SQLite printf系列函数内部调用函数sqlite3_str_vappendf,该函数处理字符串格式化。当格式替换类型为%q、%Q或%w时,对sqlite3_str_vappendf函数的大字符串输入可能导致有符号整数溢出。

sqlite3_str_vappendf扫描输入fmt字符串,并根据fmt字符串中指定的格式替换类型格式化可变大小的参数列表。在处理%q、%Q和%w格式说明符的case语句中(src/printf.c:L803-850),函数扫描输入字符串中的引号字符以计算正确的输出字节数(第824-828行),然后将输入复制到输出缓冲区,并根据需要添加引号字符(第842-845行)。在下面的代码片段中,escarg指向输入字符串:

 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
46
47
48
case etSQLESCAPE:           /* %q: Escape ' characters */
case etSQLESCAPE2:          /* %Q: Escape ' and enclose in '...' */
case etSQLESCAPE3: {        /* %w: Escape " characters */
  int i, j, k, n, isnull;
  int needQuote;
  char ch;
  char q = ((xtype==etSQLESCAPE3)?'"':'\'');   /* Quote character */
  char *escarg;

  if( bArgList ){
    escarg = getTextArg(pArgList);
  }else{
    escarg = va_arg(ap,char*);
  }
  isnull = escarg==0;
  if( isnull ) escarg = (xtype==etSQLESCAPE2 ? "NULL" : "(NULL)");
  /* For %q, %Q, and %w, the precision is the number of bytes (or
  ** characters if the ! flags is present) to use from the input.
  ** Because of the extra quoting characters inserted, the number
  ** of output characters may be larger than the precision.
  */
  k = precision;
  for(i=n=0; k!=0 && (ch=escarg[i])!=0; i++, k--){
    if( ch==q )  n++;
    if( flag_altform2 && (ch&0xc0)==0xc0 ){
      while( (escarg[i+1]&0xc0)==0x80 ){ i++; }
    }
  }
  needQuote = !isnull && xtype==etSQLESCAPE2;
  n += i + 3;
  if( n>etBUFSIZE ){
    bufpt = zExtra = printfTempBuf(pAccum, n);
    if( bufpt==0 ) return;
  }else{
    bufpt = buf;
  }
  j = 0;
  if( needQuote ) bufpt[j++] = q;
  k = i;
  for(i=0; i<k; i++){
    bufpt[j++] = ch = escarg[i];
    if( ch==q ) bufpt[j++] = ch;
  }
  if( needQuote ) bufpt[j++] = q;
  bufpt[j] = 0;
  length = j;
  goto adjust_width_for_utf8;
}

引号字符的数量(int n)和输入字符串中的总字节数(int i)用于计算输出缓冲区中所需的最大总字节数(L832: n+=i+3)。此计算可能导致n溢出为负值,例如,当int类型为32位且n=0和i=0x7ffffffe时。当输入字符串包含0x7ffffffe ASCII字符且没有引号字符时,这是可能的。

第833-838行旨在确保分配足够大小的缓冲区以接收格式化后的输入字符串字节。如果输出字符串大小可能超过etBUFSIZE字节(默认70字节),程序动态分配足够大小的缓冲区以容纳输出字符串(第834行)。否则,程序期望输出缓冲区小于堆栈分配的etBUFSIZE字节缓冲区,并使用小的堆栈分配缓冲区(第837行)。至少i字节从输入复制到目标缓冲区。当n溢出为负值时,使用堆栈分配缓冲区,即使i可能超过etBUFSIZE,导致输入字符串复制到输出缓冲区时发生堆栈缓冲区溢出(第843行)。

利用

但我们能否用此漏洞做比仅仅使目标程序崩溃更有趣的事情?当然!

输入字符串必须非常大才能达到第832行n溢出为负值的错误条件。挑战在于当输入字符串非常大时,变量i(计算输入字符串中的字节数)也非常大,导致大量数据写入堆栈,并使程序在第843行崩溃。我们着手确定是否可能在第832行导致n溢出,但同时使i在第843行保持小而正,从而避免崩溃。我们重新审视计算i的循环,从第824到830行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* For %q, %Q, and %w, the precision is the number of bytes (or
** characters if the ! flags is present) to use from the input.
** Because of the extra quoting characters inserted, the number
** of output characters may be larger than the precision.
*/
k = precision;
for(i=n=0; k!=0 && (ch=escarg[i])!=0; i++, k--){
  if( ch==q )  n++;
  if( flag_altform2 && (ch&0xc0)==0xc0 ){
    while( (escarg[i+1]&0xc0)==0x80 ){ i++; }
  }
}

此循环的目的是扫描输入字符串(escarg)中的引号字符(q),每次找到一个就递增n。如果我们的目标是导致受控的堆栈缓冲区溢出而不使程序崩溃,则循环必须以这样的值终止:n+=i+3的结果小于etBUFSIZE(定义为70的宏),且i必须是一个相对较小的正整数,大于etBUFSIZE。

循环中的k和flag_altform2变量与SQLite printf函数的两个特性相关:可选精度和可选的交替格式标志2,这两者都受格式字符串影响。在下面的示例中,在格式字符串中包含!设置flag_altform2=true,而.80设置precision=80:

1
snprintf3_snprintf(len, buf, "'%!.80q'", src)

当格式字符串中未设置精度时,默认设置为-1。因此,默认情况下int k=-1,循环每次迭代递减k,因此外部循环可以在k=0之前执行232次。

到目前为止,在我们对CVE-2022-35737的分析中,我们对传递给易受攻击函数的格式字符串做了很少的假设,除了它包含一个易受攻击的格式说明符(%Q、%q或%w)。为了进一步利用,我们需要再做一個假设:通过提供!字符在格式字符串中设置flag_altform2。

当flag_altform2=true时,通过在输入字符串中包含unicode字符,可以在不递减k的情况下递增i。考虑到这一点,也许我们可以在输入中包含足够的引号字符,将n设置为大的正整数,然后导致i在内循环中递增,直到它回绕到小的正整数,然后以某种方式退出循环。但当i溢出超过最大有符号整数值时,它将如何行为?它会回绕到0还是负值?仅通过查看源代码就能知道吗?不,不能;这是未定义行为,因此我们必须检查编译后的二进制文件,看看编译器选择了什么来表示i。

编译二进制文件中的不同表示

我们一直在Ubuntu 20.04主机上工作,并从APT包管理器安装了libsqlite.so版本3.31.1,因此我们检查该版本的编译二进制文件在Binary Ninja中的反汇编:

Binary Ninja对源代码第824到830行编译循环的反汇编,其中扫描escarg输入字符串中的引号字符。[1a]和[1b]指示源代码第825行escarg[i]; … i++。[2a]和[2b]指示源代码第828行escarg[i+1]; … i++。

在指令[1a],r10包含escarg的地址,rsi用于索引到缓冲区以从中获取值,其中rsi寄存器由紧接其前的指令中的32位edx寄存器符号扩展设置。这对应于源代码第825行的escarg[i]表达式。每次循环迭代,edx在指令[1b]递增。这意味着源代码变量i使用有符号32位整数语义表示,因此当i达到最大32位有符号整数值(0x7fffffff)时,它将在[1b]递增到0x80000000,这将符号扩展到rsi为0xffffffff80000000,并用于负索引到escarg。

然而,指令[2a]讲述了不同的故事。这里,r10仍然包含escarg的地址,但rax+1用于索引到缓冲区,对应于源代码第828行的escarg[i+1]表达式,在扫描unicode字符的内循环中。指令[2b]递增rax,但作为64位值——没有32位符号扩展——然后循环回[2a]。这里,i用无符号64位整数语义表示,因此当i超过最大有符号32位整数值(0x7fffffff)时,其下一次内存访问是到escarg+0x80000000。我们有相同源代码变量的不同表示,并且可以为相同源代码变量i的值从内存读取两个不同的值!这一发现促使我们搜索更多这些“不同表示”的实例,我们将在即将发布的博客文章中描述这一搜索。

好的,那么我们可以使用此编译怪癖来设置CVE-2022-35737更有趣利用的条件吗?事实证明,是的。

控制保存的返回地址

以下是我们试图设置的条件的快速回顾:

 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
46
47
48
case etSQLESCAPE:           /* %q: Escape ' characters */
case etSQLESCAPE2:          /* %Q: Escape ' and enclose in '...' */
case etSQLESCAPE3: {        /* %w: Escape " characters */
  int i, j, k, n, isnull;
  int needQuote;
  char ch;
  char q = ((xtype==etSQLESCAPE3)?'"':'\'');   /* Quote character */
  char *escarg;

  if( bArgList ){
    escarg = getTextArg(pArgList);
  }else{
    escarg = va_arg(ap,char*);
  }
  isnull = escarg==0;
  if( isnull ) escarg = (xtype==etSQLESCAPE2 ? "NULL" : "(NULL)");
  /* For %q, %Q, and %w, the precision is the number of bytes (or
  ** characters if the ! flags is present) to use from the input.
  ** Because of the extra quoting characters inserted, the number
  ** of output characters may be larger than the precision.
  */
  k = precision;
  for(i=n=0; k!=0 && (ch=escarg[i])!=0; i++, k--){
    if( ch==q )  n++;
    if( flag_altform2 && (ch&0xc0)==0xc0 ){
      while( (escarg[i+1]&0xc0)==0x80 ){ i++; }
    }
  }
  needQuote = !isnull && xtype==etSQLESCAPE2;
  n += i + 3;
  if( n>etBUFSIZE ){
    bufpt = zExtra = printfTempBuf(pAccum, n);
    if( bufpt==0 ) return;
  }else{
    bufpt = buf;
  }
  j = 0;
  if( needQuote ) bufpt[j++] = q;
  k = i;
  for(i=0; i<k; i++){
    bufpt[j++] = ch = escarg[i];
    if( ch==q ) bufpt[j++] = ch;
  }
  if( needQuote ) bufpt[j++] = q;
  bufpt[j] = 0;
  length = j;
  goto adjust_width_for_utf8;
}

这里是一个截图,突出显示我们想要集中的内容:

我们希望循环[1]以i和n的值终止,使得计算[2]溢出,导致n的值为负或小于etBUFSIZE(70),且i设置为相对较小的正整数,大于etBUFSIZE。这将允许循环[3]写入超出堆栈分配的bufpt的边界,但不会通过写入超出堆栈内存区域而立即导致程序崩溃。

考虑包含0x7fffff00单引号(’)字符的字符串输入,后跟单个0xc0字节(unicode前缀),然后跟足够的0x80字节,使总字符串长度达到0x100000100字节(后跟NULL字节)。让我们称此字符串为string1,并思考当此字符串传递给sqlite3_snprintf时会发生什么:

1
snprintf3_snprintf(len, buf, "'%!q'", string1)

(注意,我们已更改格式字符串以通过提供!字符允许unicode字符。)

当循环[1]扫描string1的前0x7fffff00字节时,n和i都递增到0x7fffff00。在下一个循环迭代中,程序从输入字符串读取unicode字符前缀并进入内循环,其中i用64位无符号语义表示。i变量递增到0x100000100,然后遇到NULL字节,导致内循环终止。此时在程序执行中,n=0x7fffff00,当向下转换为32位值时,i=0x100。如果循环[1]在此时终止,计算n+=i+3将导致n=0x80000003,当视为有符号值时为负。同时,i现在是一个小的正整数,但大于70(etBUFSIZE),这将导致当256(0x100)字节读入70字节的堆栈缓冲区时发生堆栈缓冲区溢出。这显示了向我们的目标取得的进展:额外几百字节写入堆栈不太可能到达堆栈内存区域的末尾,但很可能到达堆栈上保存的有趣数据,如保存的返回地址和堆

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计