-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The Length Byte Is the Announcement\n\n`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:\n\n```c\nwhile (remaining > 0 && *source != '\\0')\n{\n char c = *source;\n int len;\n int i;\n\n /* Fast path for plain ASCII */\n if (!IS_HIGHBIT_SET(c))\n {\n if (c == '\\'')\n {\n *target++ = '\\'';\n *target++ = '\\'';\n source++;\n remaining--;\n continue;\n }\n /* ... other ASCII handling ... */\n }\n\n /* Slow path for possible multibyte characters */\n len = pg_encoding_mblen(encoding, source);\n\n /* Copy the character */\n for (i = 0; i < len; i++)\n {\n if (remaining == 0 || *source == '\\0')\n break;\n *target++ = *source++;\n remaining--;\n }\n}\n```\n\nTwo 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.\n\n`pg_encoding_mblen` lives in `src/common/wchar.c`. For UTF-8 it dispatches to `pg_utf_mblen`:\n\n```c\nstatic int\npg_utf_mblen(const unsigned char *s)\n{\n int len;\n\n if ((*s & 0x80) == 0) len = 1;\n else if ((*s & 0xe0) == 0xc0) len = 2;\n else if ((*s & 0xf0) == 0xe0) len = 3;\n else if ((*s & 0xf8) == 0xf0) len = 4;\n else len = 1;\n return len;\n}\n```\n\nThe 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.\n\n`PQescapeStringInternal` does not ask it of anyone else either. The slow path takes the announcement on its word.\n\n## The Apostrophe Was the Second Byte\n\nThe 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:\n\n```python\nescaped_name = get_escaped_name(name_raw)\nsql = f\"SELECT * FROM employees WHERE name = {escaped_name};\"\nprocess = subprocess.Popen(\n ['psql', db_url],\n stdin=subprocess.PIPE,\n stdout=subprocess.PIPE,\n stderr=subprocess.PIPE,\n)\nstdout, stderr = process.communicate(input=sql.encode('utf-8', errors='ignore'))\n```\n\n`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:\n\n```python\npayload = b\"hax\\xc0'; \\\\! id; \\\\! ls /tmp; #\"\n```\n\nTrace `PQescapeStringInternal`'s loop against those bytes:\n\n| step | source byte(s) | branch | output |\n|------|----------------|--------|--------|\n| 1 | `h` `a` `x` | fast path | `hax` |\n| 2 | `\\xc0` | slow path: `pg_utf_mblen` returns 2 | (consumes 2 bytes) |\n| 3 | `\\xc0` and `'` | (copied verbatim by step 2) | `\\xc0'` |\n| 4 | `;` ` ` `\\` `!` ` ` `i` `d` `;` ` ` `\\` `!` ` ` `l` `s` ` ` `/` `t` `m` `p` `;` ` ` `#` | fast path (no special handling for `\\` outside `standard_conforming_strings = off`) | unchanged |\n\n`PQescapeLiteral` wraps the result with quotes and returns:\n\n```\n'hax\\xc0'; \\! id; \\! ls /tmp; #'\n```\n\nThe 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.\n\nThe Flask handler concatenates this into:\n\n```\nSELECT * FROM employees WHERE name = 'hax\\xc0'; \\! id; \\! ls /tmp; #';\n```\n\nThe PoC pipes that text to `psql`, reads the response, and prints `uid=0(root)`.\n\n## psql Was the Disagreeing Parser\n\nThe interesting question is why this bypass reaches code execution against `psql` and not against the libpq wire protocol.\n\nIf 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.\n\n`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.\n\nWhat 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.\n\nThe 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.\n\nThe 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.\n\n## `pg_encoding_verifymbchar` Was in the Same File\n\nThe fix is commit `92e4170f` by Andres Freund, landed February 10, 2025. The shape of the change in `PQescapeStringInternal`:\n\n```diff\n-\twhile (remaining > 0 && *source != '\\0')\n+\twhile (remaining > 0)\n \t{\n \t\tchar\t\tc = *source;\n-\t\tint\t\t\tlen;\n+\t\tint\t\t\tcharlen;\n \t\tint\t\t\ti;\n\n \t\t/* Fast path for plain ASCII */\n \t\tif (!IS_HIGHBIT_SET(c))\n \t\t{\n \t\t\t... unchanged ...\n \t\t}\n\n \t\t/* Slow path for possible multibyte characters */\n-\t\tlen = pg_encoding_mblen(encoding, source);\n-\n-\t\t/* Copy the character */\n-\t\tfor (i = 0; i < len; i++)\n+\t\tcharlen = pg_encoding_mblen(encoding, source);\n+\n+\t\tif (remaining < charlen)\n+\t\t{\n+\t\t\t/* incomplete character at end of input */\n+\t\t\t*error = true;\n+\t\t\tpg_encoding_set_invalid(encoding, target);\n+\t\t\ttarget += 2;\n+\t\t\tsource = end;\n+\t\t\tremaining = 0;\n+\t\t}\n+\t\telse if (pg_encoding_verifymbchar(encoding, source, charlen) == -1)\n+\t\t{\n+\t\t\t/* invalid byte sequence in declared encoding */\n+\t\t\t*error = true;\n+\t\t\tpg_encoding_set_invalid(encoding, target);\n+\t\t\ttarget += 2;\n+\t\t\tsource += charlen;\n+\t\t\tremaining -= charlen;\n+\t\t}\n+\t\telse\n \t\t{\n-\t\t\tif (remaining == 0 || *source == '\\0')\n-\t\t\t\tbreak;\n-\t\t\t*target++ = *source++;\n-\t\t\tremaining--;\n+\t\t\tfor (i = 0; i < charlen; i++)\n+\t\t\t{\n+\t\t\t\t*target++ = *source++;\n+\t\t\t\tremaining--;\n+\t\t\t}\n \t\t}\n \t}\n```\n\nTwo 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.\n\n`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.\n\nThis is the [design-debt-driver](/patterns/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.\n\nPoC: [TranDongA3/POC-CVE-2025-1094](https://github.com/TranDongA3/POC-CVE-2025-1094)","closing_line":"`pg_encoding_verifymbchar` was in the same file. The escape family had not called it since multibyte encodings were a feature.","hook_md":"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.\n\nFeed 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.\n\nCVE-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.","post_id":261,"slug":"pqescapeliteral-asks-how-long-not-whether","title":"CVE-2025-1094: PQescapeLiteral Asks How Long. It Does Not Ask Whether.","type":"initial","unreadable_sentence":"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."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCarARTwAKCRDeZjl4jgkQ JovSAQCyXc+j/pKae5eka0FiWvPu+DKMTI9zYEG5pCyIECwnQQEA4hEsWC+K5NBH olhcK0iAQ0ulva32S5ZbO/LLPYF14QE= =o761 -----END PGP SIGNATURE-----