//nefariousplan

CVE-2025-1094: PQescapeLiteral Asks How Long. It Does Not Ask Whether.

pattern

cve

proof of concept

PostgreSQL's PQescapeLiteral walks its input one byte at a time. When a byte has the high bit set, the function asks pg_encoding_mblen how many bytes the character occupies and consumes that many bytes verbatim. pg_encoding_mblen reads one byte to answer: the first.

Feed it hax\xc0'; \! id; #. pg_encoding_mblen(PG_UTF8, "\xc0...") returns 2, because 0xC0 has the bit pattern 110xxxxx and the UTF-8 specification says that means a 2-byte sequence. The escape function consumes 0xC0 and the next byte, the apostrophe, as if they were one character. The apostrophe never appears in the output as an apostrophe. It appears as the second half of a character whose first half announced a length the rest of the bytes never paid.

CVE-2025-1094 is what shipped in libpq's escape family for as long as PostgreSQL has supported multibyte client encodings. Stephen Fewer of Rapid7 reported it on February 13, 2025. The PostgreSQL Global Development Group fixed it in 17.3, 16.7, 15.11, 14.16, and 13.19 by calling a function that already lived in the same source file the escape family had not called for twenty years.

The Length Byte Is the Announcement

PQescapeStringInternal is the helper called by all four of PQescapeLiteral, PQescapeIdentifier, PQescapeString, and PQescapeStringConn, and it lives in src/interfaces/libpq/fe-exec.c. The pre-patch loop, condensed to the relevant cases:

while (remaining > 0 && *source != '\0')
{
    char c = *source;
    int  len;
    int  i;

    /* Fast path for plain ASCII */
    if (!IS_HIGHBIT_SET(c))
    {
        if (c == '\'')
        {
            *target++ = '\'';
            *target++ = '\'';
            source++;
            remaining--;
            continue;
        }
        /* ... other ASCII handling ... */
    }

    /* Slow path for possible multibyte characters */
    len = pg_encoding_mblen(encoding, source);

    /* Copy the character */
    for (i = 0; i < len; i++)
    {
        if (remaining == 0 || *source == '\0')
            break;
        *target++ = *source++;
        remaining--;
    }
}

Two paths. The fast path handles ASCII bytes and doubles ' into ''. The slow path asks pg_encoding_mblen how many bytes the character occupies and copies that many bytes verbatim. The apostrophe check is in the fast path. The slow path never reaches it.

pg_encoding_mblen lives in src/common/wchar.c. For UTF-8 it dispatches to pg_utf_mblen:

static int
pg_utf_mblen(const unsigned char *s)
{
    int len;

    if      ((*s & 0x80) == 0)    len = 1;
    else if ((*s & 0xe0) == 0xc0) len = 2;
    else if ((*s & 0xf0) == 0xe0) len = 3;
    else if ((*s & 0xf8) == 0xf0) len = 4;
    else                          len = 1;
    return len;
}

The function reads one byte and returns the length the byte announces. It does not look at any following byte. It does not check whether the next byte is a valid UTF-8 continuation byte, which the specification requires to fall in 0x80 through 0xBF. If you hand it 0xC0, it returns 2. Whether the byte after 0xC0 is the continuation byte the grammar requires is not a question this function answers.

PQescapeStringInternal does not ask it of anyone else either. The slow path takes the announcement on its word.

The Apostrophe Was the Second Byte

The PoC at TranDongA3/POC-CVE-2025-1094 is a Flask app that loads libpq.so.5 via ctypes, calls PQescapeLiteral directly, and pipes the resulting SQL into psql with subprocess.Popen. The handler:

escaped_name = get_escaped_name(name_raw)
sql = f"SELECT * FROM employees WHERE name = {escaped_name};"
process = subprocess.Popen(
    ['psql', db_url],
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
)
stdout, stderr = process.communicate(input=sql.encode('utf-8', errors='ignore'))

get_escaped_name is twelve lines of ctypes wrapping PQconnectdb, PQescapeLiteral, and PQfreemem. The container is postgres:16.6, released November 14, 2024, three months before the CVE-2025-1094 fix. The default client_encoding is UTF8. The exploit script sends:

payload = b"hax\xc0'; \\! id; \\! ls /tmp; #"

Trace PQescapeStringInternal's loop against those bytes:

step source byte(s) branch output
1 h a x fast path hax
2 \xc0 slow path: pg_utf_mblen returns 2 (consumes 2 bytes)
3 \xc0 and ' (copied verbatim by step 2) \xc0'
4 ; \ ! i d ; \ ! l s / t m p ; # fast path (no special handling for \ outside standard_conforming_strings = off) unchanged

PQescapeLiteral wraps the result with quotes and returns:

'hax\xc0'; \! id; \! ls /tmp; #'

The leading ' and the trailing ' are the ones libpq added. The apostrophe at byte 5 is the one the attacker supplied, and it is not a string delimiter in libpq's view of its own output. It is a continuation byte for a character pg_utf_mblen announced and the bytes did not pay.

The Flask handler concatenates this into:

SELECT * FROM employees WHERE name = 'hax\xc0'; \! id; \! ls /tmp; #';

The PoC pipes that text to psql, reads the response, and prints uid=0(root).

psql Was the Disagreeing Parser

The interesting question is why this bypass reaches code execution against psql and not against the libpq wire protocol.

If the Flask handler had run the query through PQexec instead of piping to psql, those bytes would have travelled to the server in a wire-protocol Q-message, and the server's lexer in src/backend/parser would have tokenized them. The server's lexer, on high-bit bytes inside a string literal, calls the same pg_encoding_mblen. It consumes the same two bytes for \xc0 followed by '. The server's view of the string is hax<2-byte-mb>; \! id; \! ls /tmp; #, one literal, no second statement, no shell. The escape function and the server's parser agree on what bytes are inside the string, because they both ask the same question of the same function and accept the same answer.

psql is a different parser. The lexer in psqlscan.l is encoding-aware and fault-tolerant in a way the server's parser is not. It has to remain usable when invalid bytes appear mid-statement at an interactive prompt, when an .sql file from an unknown encoding gets sourced, when the user pastes nonsense. On an invalid multibyte sequence inside a string literal, psql's lexer terminates the string at a byte the server's lexer would have consumed. The 0xC0 is one unknown byte. The apostrophe that follows is the closing quote. The string ends there, in psql's view of the same bytes libpq just escaped.

What follows that closing quote is ;, which psql treats as a statement terminator. The next token psql sees is \!. Backslash commands are processed at the top level of psql's input loop. The fact that psql's stdin is a pipe and not a terminal is not consulted. \! invokes system(3) on the rest of the line, the SQL handler concatenates the result back into stdout, and the Flask response carries uid=0(root) to the caller.

The escape was correct for one consumer and exploitable for another. The PostgreSQL advisory phrases this as "in certain usage patterns": the SQL injection becomes reachable when the escape output is fed to psql rather than to a libpq query call. The carve-out the advisory adds for client_encoding = BIG5 paired with server_encoding of EUC_TW or MULE_INTERNAL is the case where the asymmetry exists between libpq's escape and the server's parser as well, because client and server are reading the same bytes against different multibyte tables.

The victim is a class of developer that the PostgreSQL documentation has, for two decades, told to use PQescapeLiteral: the operator who built a small admin tool that takes a string, escapes it with the API the manual recommends, and ships the result to psql through a subprocess pipe because that was the obvious way to run a query against a remote server. The handler in app.py is not a contrived test rig. It is the shape of every backup script, every migration helper, every "search by name" page that someone wrote on a Wednesday afternoon. The contract on PQescapeLiteral does not mention psql. The escape function was tested against the parser that does not get this wrong.

pg_encoding_verifymbchar Was in the Same File

The fix is commit 92e4170f by Andres Freund, landed February 10, 2025. The shape of the change in PQescapeStringInternal:

-	while (remaining > 0 && *source != '\0')
+	while (remaining > 0)
 	{
 		char		c = *source;
-		int			len;
+		int			charlen;
 		int			i;

 		/* Fast path for plain ASCII */
 		if (!IS_HIGHBIT_SET(c))
 		{
 			... unchanged ...
 		}

 		/* Slow path for possible multibyte characters */
-		len = pg_encoding_mblen(encoding, source);
-
-		/* Copy the character */
-		for (i = 0; i < len; i++)
+		charlen = pg_encoding_mblen(encoding, source);
+
+		if (remaining < charlen)
+		{
+			/* incomplete character at end of input */
+			*error = true;
+			pg_encoding_set_invalid(encoding, target);
+			target += 2;
+			source = end;
+			remaining = 0;
+		}
+		else if (pg_encoding_verifymbchar(encoding, source, charlen) == -1)
+		{
+			/* invalid byte sequence in declared encoding */
+			*error = true;
+			pg_encoding_set_invalid(encoding, target);
+			target += 2;
+			source += charlen;
+			remaining -= charlen;
+		}
+		else
 		{
-			if (remaining == 0 || *source == '\0')
-				break;
-			*target++ = *source++;
-			remaining--;
+			for (i = 0; i < charlen; i++)
+			{
+				*target++ = *source++;
+				remaining--;
+			}
 		}
 	}

Two new checks. The first asks whether the announced length fits in what is left of the input. The second calls pg_encoding_verifymbchar. When either fails, the escape function writes a two-byte invalid-sequence marker, sets an error flag the caller can read through PQescapeStringConn, and refuses to produce a string that pretends 0xC0 plus an apostrophe is one character.

pg_encoding_verifymbchar lives in src/common/wchar.c, the same file as pg_encoding_mblen. It takes the bytes the announcement-reader read and the length the announcement-reader returned, and it checks that the following bytes are valid for the declared encoding. For UTF-8: that the continuation bytes are 10xxxxxx. For BIG5: that the trail byte lies in the legal range. The function has been in the tree since the pg_verify_mbstr family was added in the early 2010s. Six functions in src/interfaces/libpq and src/fe_utils that promised to neutralize quoting syntax in attacker-influenced input did not call it.

This is the design-debt-driver shape in its small-substrate form: the validator existed in the same translation unit as the bug. The pattern was already named. Nobody wired the name to the verb. The substrate is not the absence of pg_encoding_verifymbchar from the codebase. The substrate is the choice to copy bytes on the word of pg_encoding_mblen alone, in functions whose names promise that the output is safe to send to a SQL parser. The 17.3 / 16.7 / 15.11 / 14.16 / 13.19 patch closed the call sites the audit visited. The next escape function someone writes against pg_encoding_mblen without pg_encoding_verifymbchar, in a new tool, in a new front-end utility, in a new bindings library that re-implements escaping for performance, is the next entry into the same primitive. The escape family had not called the verifier since multibyte encodings were a feature. There is no in-tree mechanism that requires the next caller to.

PoC: TranDongA3/POC-CVE-2025-1094

pg_encoding_verifymbchar was in the same file. The escape family had not called it since multibyte encodings were a feature.