Violet RE logo: two offset V glyphs on a dark tile Violet REStatic reverse engineering

seriesanalysistargetSQLite 3.39.1 / 3.39.2

.target

Two SQLite builds. Same size, same symbols. One of them has CVE-2022-35737.

Put the 3.39.1 and the 3.39.2 build of SQLite side by side and the outside of the file tells you almost nothing. Identical length to the byte. Identical symbol table. One defined symbol between them. The only two readable strings that differ are the version number and the build fingerprint, and neither of those is the defect. The only way to tell these two apart is to read the code.

every string that differs between the two builds strings -n 6, sorted, diffed
$ diff <(strings -a -n 6 binary_vulnerable | sort -u) \
          <(strings -a -n 6 binary_patched    | sort -u)
648c648
< 2022-07-13 19:41:41 7c16541a0efb3985578181171c9f2bb3fdc4bad6a2ec85c6e31ab96f3eff201f
---
> 2022-07-21 15:24:47 698edb77537b67c41adc68f9b892db56bcf9a55e00371a61420f3ddd668e6603
653c653
< 3.39.1
---
> 3.39.2
2185a2186
> Tkk|8+

Two build-metadata strings and one accident. Tkk|8+ is not text at all: it is a run of printable bytes inside arm64 machine code that strings cannot distinguish from a string. Everything that matters about this vulnerability is invisible here.

Violet RE did not find this vulnerability. It was reported publicly and fixed by the SQLite project in July 2022. This page is an analysis of a known, published fix, written to show what reading a stripped binary actually involves.

.record

What CVE-2022-35737 actually is.

The CVE record describes it in one sentence: SQLite 1.0.12 through 3.39.x before 3.39.2 sometimes allows an array-bounds overflow if billions of bytes are used in a string argument to a C API. NVD scores it 7.5 HIGH and classes it as CWE-129, improper validation of an array index.

Read the vector before you read the number. It is CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H. The confidentiality and integrity impacts are both scored none. Officially this is an availability bug, and a 7.5 here does not mean what a 7.5 with C:H/I:H would mean.

The SQLite project states the reachability limits itself, and they are narrow. The bug is only accessible through some of the C-language interfaces. It cannot be reached from SQL, and it cannot be reached by handing SQLite a corrupt database file. It needs a string argument longer than two billion bytes, and even then only under specific circumstances.

reachable via
SQLite's own printf family: sqlite3_mprintf, sqlite3_snprintf, sqlite3_str_appendf and every internal caller that quotes a string. They all route into sqlite3_str_vappendf.
format specifier
%q, %Q or %w, the SQL-quoting substitutions that escape a single or double quote character.
input size
In the region of two billion bytes. Trail of Bits, whose write-up works the defect through in detail, state a crash input of 0x7fffff00 quote characters, that is 2,147,483,392 of them, just under the signed 32-bit limit, sitting inside a buffer of roughly four gigabytes.
mechanism
Signed 32-bit overflow of the loop counters inside the quoting branch. A wrapped count produces a negative array index and a size that passes a check it should have failed.
outcome
Graded. A large input crashes the process. The stronger result reported by Trail of Bits, overwriting a saved return address, needs a roughly four gigabyte unicode-prefix buffer and a compiler that emits divergent representations of the same variable, and is stated as confirmed only when the library is built without stack canaries.

Both of those preconditions belong to the Trail of Bits research, not to the CVE record. The official scoring covers availability only. If you see this CVE described as remote code execution with no conditions attached, that description has outrun its source.

The upstream fix, and what it was called at the time.

Two check-ins by D. Richard Hipp on 18 July 2022, one file, src/printf.c. Trunk aab790a16e1bdff7, and 26db4fc22fe66658 on branch-3.39, which is the one that shipped in 3.39.2 three days later. The check-in comments today read Increase the size of loop variables in the printf() implementation to avoid integer overflow on multi-gigabyte string arguments. CVE-2022-35737.

That is not what they said when they were committed. The check-in page still carries the earlier text beneath, under Original Comment: Increase the size of loop variables in the printf() implementation to avoid harmless compiler warnings. The CVE reference was added later, by editing the comment, and the page records no timestamp for that edit. If you were bisecting the 3.39.1 to 3.39.2 range on commit messages before it happened, the fix for this CVE was labelled a warning cleanup.

.builds

What the two binaries are.

These are our own builds. They are not shipped by the SQLite project and they are not anybody's release artefact. SQLite itself is in the public domain, so the source quoted further down carries no licence restriction; the amalgamation states as much in its own header. We took the published amalgamations for 3.39.1 and 3.39.2, compiled each one on arm64 macOS with the same compiler and the same flags, and stripped both.

how each build was produced recipe, not output
# the released amalgamation for each version, unpacked in turn
$ cc -O1 -o sqlite_cli sqlite3.c shell.c -lpthread -lm
$ strip sqlite_cli

The identical file size further down is a consequence of that identical configuration, and of arm64 page padding. It is not a general property of two adjacent vendor releases, and you should not expect to see it in builds you did not make yourself.

The version claim is checkable without trusting us. Each amalgamation embeds a SQLITE_SOURCE_ID, and the SQLite project publishes that string in the release log. The two fingerprints in the hero pane above are those published identifiers, byte for byte. One curl against sqlite.org and one strings against the binary settles which release a build came from.

3.39.1
Released 13 July 2022. SQLITE_SOURCE_ID begins 2022-07-13 19:41:41 7c16541a. This is the vulnerable build.
3.39.2
Released 21 July 2022. SQLITE_SOURCE_ID begins 2022-07-21 15:24:47 698edb77. This is the patched build.
format
Mach-O 64-bit executable, arm64, stripped. Built -O1, linked against pthread and libm.
caveat
Every address on this page is specific to these two files. A different compiler, a different version of it or a different optimisation level will lay the code out differently, which is why the SHA-256 of each file is recorded below.

.surface

What the outside of the file gives you.

Four commands, run against both files. Every line of output below is verbatim.

identity file, wc, shasum
$ file binary_vulnerable binary_patched
binary_vulnerable: Mach-O 64-bit executable arm64
binary_patched:    Mach-O 64-bit executable arm64

$ wc -c binary_vulnerable binary_patched
 1137792 binary_vulnerable
 1137792 binary_patched
 2275584 total

$ shasum -a 256 binary_vulnerable binary_patched
6a1c62f07c0093c149c4c5855ff7b1994024a2a4bec2716b5a1284c9de6a3cdc  binary_vulnerable
443c7424378c869e02017c9bb567cdad6dbb9471148d5f980d48efadb99a04e9  binary_patched

Same format, same length to the byte, different content. A size comparison distinguishes nothing here.

symbols nm
$ nm -a binary_vulnerable | wc -l
     135
$ nm -a binary_patched | wc -l
     135
$ diff <(nm -a binary_vulnerable) <(nm -a binary_patched); echo $?
0

$ nm -U binary_vulnerable | wc -l
       1

The symbol tables are identical, and they are nearly empty. One defined symbol in a 1.1 MB executable, __mh_execute_header; the other 134 entries are undefined imports and a debug stub. Plain nm reports 134 lines rather than 135, because the stub only appears with -a. Either flag, the two builds agree. Everything of interest in this program is anonymous.

So the cheap comparisons are exhausted. Size says nothing, symbols say nothing, and the strings diff in the hero says only which release each file came from. At this point either you read the code or you stop.

.delta

How much actually differs.

Compare the files byte by byte and the answer looks catastrophic.

byte-level difference, and the reason for it cmp, otool
$ cmp -l binary_vulnerable binary_patched | wc -l
  771321

$ otool -l binary_vulnerable | grep -A4 'sectname __text' | grep size
      size 0x00000000000efcf8
$ otool -l binary_patched    | grep -A4 'sectname __text' | grep size
      size 0x00000000000efd4c

771,321 of 1,137,792 byte positions differ, 67.8 per cent of the file. The second pair of commands explains it: __text grew by 0x54, that is 84 bytes. The file length did not change because the segment is page-padded and the growth fits inside the padding. But every string address after the change shifted, every call target shifted, and every adrp and add pair that materialises an address now carries a different immediate. That drift is what the 67.8 per cent is made of.

To get a real number, disassemble both __text sections linearly, strip the address column, and rewrite call targets and literal-pool offsets to placeholders so that pure relocation drift collapses to nothing.

normalised instruction diff radare2 6.1.8, sed, diff
$ r2 -q -e scr.color=0 -c 'e asm.lines=false; e asm.comments=false;
          e asm.flags=false; e asm.functions=false; e asm.bytes=false;
          pD 982264 @ 0x100000688' binary_vulnerable > text_vuln.asm
# and the same against binary_patched, length 982348, into text_patch.asm

$ wc -l text_vuln.asm text_patch.asm
  245566 text_vuln.asm
  245587 text_patch.asm
  491153 total

$ sed -E -e 's/^0x[0-9a-f]+ +//' \
         -e 's/0x1[0-9a-f]{8}/ADDR/g' \
         -e 's/sym\.func\.[0-9a-f]+/FUNC/g' \
         -e 's/^(add +x[0-9]+, x[0-9]+,) +0x[0-9a-f]+$/\1 IMM/' \
         text_vuln.asm > C_vuln.txt
# and the same for text_patch.asm into C_patch.txt

$ diff C_vuln.txt C_patch.txt | grep -c '^<'
722

722 instructions out of 245,566 genuinely differ, 0.29 per cent, and the whole release adds a net 21 instructions. Every rule in that sed is carrying weight, and you can watch it happen by truncating the script. Stop after the first rule, having removed only the address column, and the diff is 44,570 lines. Add the second, which rewrites every absolute address to ADDR, and it is 11,430. Add the third, which folds radare2's synthetic sym.func. labels to FUNC, and it is 4,916. The last rule collapses the add half of each adrp and add address materialisation, and takes it to 722. Some fraction of those 722 is the compiler reassigning registers because live ranges moved, so treat 722 as an upper bound on real logic change rather than an exact count.

Map those 722 instructions back onto radare2's function list and 720 of them land inside 107 of the 1,921 functions radare2 finds; the other two are instructions in the padding between functions. The function holding the most changed instructions in the entire binary is the one at 0x100002778, with 188.

changed instructions by function, top five of 107 radare2 aflj, python3
$ r2 -q -e scr.color=0 -A -c aflj binary_vulnerable > funcs.json
$ diff C_vuln.txt C_patch.txt > D.txt

$ python3 - <<'PY'
import re, json, bisect, collections
fn = sorted((f['addr'], f['size']) for f in json.load(open('funcs.json')))
lo = [f[0] for f in fn]
asm = open('text_vuln.asm').read().split('\n')
hit = collections.Counter()
for line in open('D.txt'):
    m = re.match(r'^(\d+)(?:,(\d+))?[cd]', line)
    if not m: continue
    for n in range(int(m.group(1)), int(m.group(2) or m.group(1)) + 1):
        a = int(asm[n-1].split()[0], 16)
        i = bisect.bisect_right(lo, a) - 1
        if a < fn[i][0] + fn[i][1]: hit[fn[i]] += 1
print(sum(hit.values()), 'lines in', len(hit), 'functions')
for (a, s), n in hit.most_common(5):
    print('  %#x %9d B %7d' % (a, s, n))
PY
720 lines in 107 functions
  0x100002778      8332 B     188
  0x1000635b8      9172 B     100
  0x10008499c       580 B      90
  0x10008f77c      5952 B      50
  0x1000d7aec     29552 B      30

Columns are address, the size radare2 reports for the function, and changed instructions. The top row is sqlite3_str_vappendf, and the script does not know that: the name is nowhere in the binary, and the identification is argued in the next section but one. The other four were not individually attributed. Rank one by a wide margin is the useful part; the exact counts below it will move if you analyse the binary at a different radare2 level, because the function boundaries move with it.

A release is not a patch.

This is the trap in binary diffing two adjacent versions of anything. 3.39.2 is an ordinary point release. Diff the two amalgamations and there are twenty hunks. Nineteen of them have nothing to do with this CVE: a RIGHT JOIN correctness fix in the query planner, two FTS3 memory leaks, assert casts for a bitfield warning, a change in the name resolver, a stat4 assertion. One hunk is CVE-2022-35737.

A reader who diffs 3.39.1 against 3.39.2, finds 107 changed functions and declares victory has found a release, not a vulnerability. Isolating the single hunk that matters is the actual work, and the count of changed functions is no help at all in doing it.

.fix

The fix adds no bounds check.

This is the part worth sitting with. The patch introduces no new comparison anywhere. It does not add a length test, it does not add a guard, it does not reject an input it previously accepted. It widens four loop counters from 32 bits to 64.

In source terms the entire published fix for this CVE is a declaration:

src/printf.c, sqlite3_str_vappendf, the %q / %Q / %w case the whole patch
       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;
+        i64 i, j, k, n;
+        int needQuote, isnull;
         char ch;
         char q = ((xtype==etSQLESCAPE3)?'"':'\'');   /* Quote character */
         char *escarg;

Four characters of type. Everything below is what those four characters do to the machine code.

The counting loop.

The function first walks the argument to count the quote characters it will have to double. Both builds place the function at 0x100002778, so the two listings read side by side.

3.39.1, 0x1000034bc vulnerable
0x1000034bc  mov  w8, 0        ; n = 0
0x1000034c0  mov  w28, 0       ; i = 0
0x1000034c4  add  x9, x27, 1
0x1000034cc  add  w28, w28, 1  ; i++  32-bit
0x1000034d0  subs w22, w22, 1  ; k--  32-bit
0x1000034d8  sxtw x10, w28     ; sign-extend i
0x1000034dc  ldrb w11, [x27, x10]
0x1000034e8  cinc w8, w8, eq   ; n++  32-bit
3.39.2, 0x100003514 patched
0x100003514  mov  x8, 0        ; n = 0  64-bit
0x100003518  mov  x28, 0       ; i = 0  64-bit
0x10000351c  sxtw x9, w26      ; k widened once
0x100003520  add  x10, x27, 1
0x100003528  add  x28, x28, 1  ; i++  64-bit
0x10000352c  subs x9, x9, 1    ; k--  64-bit
0x100003534  ldrb w11, [x27, x28] ; no sxtw
0x100003540  cinc x8, x8, eq   ; n++  64-bit

One instruction carries the bug. w28 is the byte index into the caller's string. Once it passes 0x7fffffff, sxtw x10, w28 sign-extends a negative 32-bit value into a negative 64-bit offset, and ldrb w11, [x27, x10] reads below the buffer. That is CWE-129 made visible: improper validation of an array index, expressed as a single sign-extension inside a loop. The patched build keeps the index 64 bits wide throughout and widens the precision argument once, outside the loop, where it cannot wrap.

The guard that was always there.

Next the function computes how much space the quoted copy needs, and decides whether the 70-byte stack buffer will do or whether it must allocate.

3.39.1, 0x100003530 vulnerable
0x100003530  add  w8, w28, w8  ; n = i + n
0x100003534  add  w1, w8, 3    ; n += 3
0x100003538  cmp  w1, 0x47     ; 32-bit
0x10000353c  b.lt 0x100003550

; inlined allocation guard
0x10000355c  ldr  w8, [x19, 0x10] ; nAlloc
0x100003560  cmp  w8, w1          ; 32-bit
0x100003564  b.hs 0x1000035e4
0x100003568  ldr  w8, [x19, 0x14] ; mxAlloc
0x10000356c  cmp  w8, w1
0x100003570  b.hs 0x1000035e4
3.39.2, 0x100003588 patched
0x100003588  add  x8, x28, x8  ; n = i + n
0x10000358c  add  x1, x8, 3    ; n += 3
0x100003590  cmp  x1, 0x47     ; 64-bit
0x100003594  b.lt 0x1000035a8

; same guard, same place
0x1000035b4  ldr  w8, [x19, 0x10] ; nAlloc
0x1000035b8  cmp  x1, x8          ; 64-bit
0x1000035bc  b.le 0x10000363c
0x1000035c0  ldr  w8, [x19, 0x14] ; mxAlloc
0x1000035c4  cmp  x1, x8
0x1000035c8  b.le 0x10000363c

The SQLITE_TOOBIG guard exists in both builds, at the same point, with the same shape. It was never missing. In 3.39.1 it is simply handed a value that has already wrapped negative, so it passes, the code takes the branch that uses the small stack buffer, and the copy proceeds against a size that bears no relation to the data. The patch does not add the check. It makes the check's input trustworthy. That distinction is the difference between reading a diff and understanding one.

The write.

3.39.1, 0x100003950 vulnerable
0x100003950  cmp  w28, 1
0x100003964  strb w26, [x24, w8, sxtw]
0x100003968  add  w8, w8, 1    ; j++  32-bit
0x10000396c  strb wzr, [x24, w8, sxtw]
3.39.2, 0x1000039a8 patched
0x1000039a8  cmp  x28, 1       ; 64-bit
0x1000039c8  strb w22, [x24, x8]
0x1000039cc  add  x8, x8, 1    ; j++  64-bit
0x1000039d4  strb wzr, [x24, x8]

A byte store through a sign-extended 32-bit index, into a buffer whose size came from a wrapped count. Across the whole function, four sxtw uses are removed from inside loops and one is added outside them: 22 in the vulnerable build, 19 in the patched. The function grew by exactly four bytes, one arm64 instruction, from 8,332 to 8,336.

Static evidence only. Nothing here was executed. Triggering this would need a string of roughly two gigabytes passed to %q, %Q or %w, with SQLite's maximum length raised past its default and a build configured to permit an allocation that large. No reproduction is claimed.

.locate

Finding a function in a stripped binary.

Everything above names sqlite3_str_vappendf. That name is not in either file. Here is how the function was identified, three ways, none of which needs the source.

Anchor on a string only the target uses.

The %Q handler emits the literal (NULL), with parentheses, when the argument is null. That exact spelling is rare.

one cross-reference, one function rabin2, radare2
$ rabin2 -z binary_vulnerable | grep -F '(NULL)'
241  0x000f6296 0x1000f6296 6    7    3.__TEXT.__cstring     ascii   (NULL)

$ r2 -q -e scr.color=0 -A -c 'axt @ 0x1000f6296' binary_vulnerable
sym.func.100002778 0x1000034a0 [STRN:r--] add x8, x8, str._NULL_

Exactly one cross-reference, in the function beginning at 0x100002778. In the patched build the same string sits at 0x1000f62f6, 0x60 further on, which is the layout drift from the previous section. Query the wrong address and axt returns nothing.

Confirm with the format table.

SQLite's printf engine scans a static table of format specifiers. The scan loop is plainly visible in the disassembly: a counter of 23, a base address, a comparison against the first byte of an entry, and a stride of 6. Read the first byte of each of those 23 entries.

decoding the table at 0x1000f122a radare2, python3
; the scan loop, as disassembled
mov w9, 0x17                 ; 23 iterations
adrp x26, 0x1000f1000
add x26, x26, 0x22a          ; table base
ldrsb w10, [x26]             ; first byte of entry
cmp w8, w10
add x26, x26, 6              ; 6-byte stride

$ r2 -q -e scr.color=0 -c 'p8 138 @ 0x1000f122a' binary_vulnerable | tr -d '\n' | \
     python3 -c "import sys; b = bytes.fromhex(sys.stdin.read().strip()); \
     print(''.join(chr(b[i*6]) for i in range(23)))"
dsgzqQwcouxXfeEGin%pTSr

That is SQLite's fmtinfo[] table, in order, byte for byte. The fourth field of each entry is the internal type code, and q is 0x09, Q is 0x0a, w is 0x0e, matching etSQLESCAPE, etSQLESCAPE2 and etSQLESCAPE3. A function that owns the format-specifier table is the printf implementation, whatever it is called.

Confirm with struct field offsets.

The first argument is an sqlite3_str*. The offsets the function loads from are 0x10, 0x14, 0x18, 0x1c and 0x1d, the last two as bytes. That is exactly nAlloc, mxAlloc, nChar, accError and printfFlags, laid out after a pointer to the database handle and a pointer to the text buffer. Three independent lines of evidence, all agreeing, and none of them requiring a symbol table.

Full disclosure on method: the released source ships alongside these binaries in our fixture directory. The identification above was done from the binaries first and stands on its own, but the source was then used to confirm what the disassembly showed, and to establish which of the twenty release hunks belonged to this CVE. That is a cross-check, and pretending otherwise would be dishonest.

.limits

What Violet RE would tell you about this binary.

It would not find it.

Fed this binary, Violet RE would not find CVE-2022-35737. It is worth being exact about why. The bounded static pass carries rules for sixteen weakness classes, and improper validation of an array index is not one of them. The closest rule in the set is a text match for a literal call to strcpy, which this code does not contain.

The defect is a type width. In 3.39.1 the quoting branch of sqlite3_str_vappendf declares its counters as int, and in 3.39.2 they become i64. The loop either side of that patch is the same loop, character for character. Nothing in the text distinguishes it from every other correct counted loop in an 8 MB file, because the distinguishing property is a 32-bit counter meeting an input of roughly 2GB. The rule that does not exist would not have matched the code that does not differ.

What you would get back is the severity count, and on a paid plan the recovered source, the rename record, and the line the analyser emits when nothing matched: that no evidence-backed vulnerability was established by this pass, and that absence of a finding is not evidence of absence.

It might also raise an unrelated path-handling candidate, because SQLite reads environment variables and opens files and that pairing is exactly what the traversal rule looks for. That would be a candidate about other code, not about this CVE. Pattern checks catch pattern defects. This one is an arithmetic defect that only exists at a scale the pattern never sees, and saying so is more use to you than a claim we cannot support.

  • no advisory matching The public service runs a bounded static pass and nothing else. It does not query an advisory database, it does not compare a build against a list of known vulnerabilities, and its customer manifest has no field in which to return a CVE identifier. The version fingerprint on this page came from our own strings command, by hand. It is not a product output.
  • nothing is executed Targets are never run on the public tier. The whole of this page is static evidence, which is why it stops at the instruction level and makes no claim about what happens at runtime.
  • a finding is a candidate Rule-based checks miss things, and a clean result is not a clearance. Zero matches mean that none of the checks that ran found a match. On this target you would get zero matches for this defect, or at most the one unrelated candidate described above, and either would be accurate.
  • what it is for Reading code you do not have the source to, and checking it for the weakness classes that do have a textual signature. Injection, path traversal, unsafe deserialisation, hard-coded credentials, disabled certificate checks. Those are the defects a pattern can see. This one is not.

.eof

The point.

Two files of identical length, with identical symbol tables, differing in two readable strings, neither of which describes the defect. Sixty-eight per cent of the bytes differ and 0.29 per cent of the instructions do. One of those instructions is a sign-extension inside a loop, and removing it is the entire published fix for a CVE that stood for nearly twenty-two years.

None of that is visible from the outside of the file. It is visible from the code, and only from the code.

Binaries you can't read are binaries you can't trust.

Violet RE recovers readable source from compiled code and checks it for known weakness types, with the limits of each check stated. violetre.io