.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.
$ 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_appendfand every internal caller that quotes a string. They all route intosqlite3_str_vappendf. - format specifier
%q,%Qor%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
0x7fffff00quote 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.
- CVE recordcve.org/CVERecord?id=CVE-2022-35737
- NVD entry, score and CWEnvd.nist.gov/vuln/detail/CVE-2022-35737
- SQLite's own statement of reachabilitysqlite.org/cves.html
- Release logs, with the source identifiers used belowsqlite.org/releaselog/3_39_1.html and sqlite.org/releaselog/3_39_2.html
- The check-in that shipped in 3.39.2sqlite.org/src/info/26db4fc22fe66658
- Disclosing researcher's write-up, Andreas Kellas, Trail of Bits, 25 October 2022blog.trailofbits.com/2022/10/25/sqlite-vulnerability-july-2022-library-api/
.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.
# 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_IDbegins2022-07-13 19:41:41 7c16541a. This is the vulnerable build. - 3.39.2
- Released 21 July 2022.
SQLITE_SOURCE_IDbegins2022-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.
$ 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.
$ 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.
$ 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.
$ 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.
$ 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:
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.
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
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.
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
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.
0x100003950 cmp w28, 1 0x100003964 strb w26, [x24, w8, sxtw] 0x100003968 add w8, w8, 1 ; j++ 32-bit 0x10000396c strb wzr, [x24, w8, sxtw]
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.
$ 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.
; 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
stringscommand, 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