BUG #19600: pgcrypto crypt() can return text with invalid encoding

Started by PG Bug reporting form3 months ago3 messagesbugs
Jump to latest
#1PG Bug reporting form
noreply@postgresql.org

The following bug has been logged on the website:

Bug reference: 19600
Logged by: Michael Malis
Email address: malis@pgrust.com
PostgreSQL version: 19beta1
Operating system: Debian
Description:

pgcrypto's crypt() copies bytes from the caller-supplied setting/salt
verbatim into its text result without regard to character boundaries. When a
multibyte character straddles the fixed byte count that gets copied, the
result is a text value containing a truncated UTF-8 sequence.

That value is invalid in the database encoding. The server rejects the exact
same bytes when they are offered as input, yet it accepts them from crypt()
and lets them into a table, after which upper() fails on the stored value
and
the row cannot be restored from a dump.

This is the same class that was already accepted and fixed for pgcrypto's
other text-returning functions. The thread "Encoding protection for
pgcrypto"
added pg_verifymbstr() to pgp_sym_decrypt_text and pgp_pub_decrypt_text on
the
stated principle that "by default the PostgreSQL TEXT type should have the
same encoding as the database encoding". crypt() is the same class and was
NOT covered. Unlike those functions it has no bytea counterpart, so the
escape
hatch offered in that thread ("use pgp_*_decrypt_bytea") does not exist
here.

Reproducer
----------
Needs pgcrypto and a UTF8 database. Nothing here requires superuser; crypt()
is EXECUTE-able by PUBLIC.

CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- the input salt is VALID UTF-8: U+20AC then 'A' -> e2 82 ac 41
SELECT encode(E'€A'::bytea, 'hex');

-- crypt() returns text whose bytes are NOT valid UTF-8
SELECT encode(crypt('password', E'€A')::bytea, 'hex');
-- e282555a6f49796a2f48792f63 <-- begins e2 82 55

-- the server rejects those same bytes on input
SELECT convert_from(decode('e282', 'hex'), 'UTF8');
-- ERROR: invalid byte sequence for encoding "UTF8": 0xe2 0x82

-- yet the value stores fine ...
CREATE TEMP TABLE t (h text);
INSERT INTO t SELECT crypt('password', E'€A');
SELECT octet_length(h) FROM t; -- 13

-- ... and then breaks a core text function
SELECT upper(h) FROM t;
-- ERROR: invalid byte sequence for encoding "UTF8": 0xe2 0x82 0x55

-- and the row does not survive dump/restore
\copy t TO '/tmp/crypt_invalid.txt'
CREATE TEMP TABLE t2 (h text);
\copy t2 FROM '/tmp/crypt_invalid.txt'
-- ERROR: invalid byte sequence for encoding "UTF8": 0xe2 0x82 0x55
-- CONTEXT: COPY t2, line 1

Two controls, both of which pass, so this is the geometry and not an
artifact of the test:

-- the documented idiom cannot trigger it: gen_salt() emits ASCII only
SELECT convert_from(crypt('password', gen_salt('des'))::bytea, 'UTF8')
IS NOT NULL; -- t

-- a multibyte character PAST the 2-byte cut is fine
SELECT convert_from(crypt('password', E'AB€')::bytea, 'UTF8')
IS NOT NULL; -- t

Expected vs. actual
-------------------
- Expected: crypt() either rejects a setting it cannot represent in the
database encoding, or returns a value that is valid in that encoding —
the same contract pgp_sym_decrypt_text and pgp_pub_decrypt_text now
honour.
- Actual: crypt() returns text containing a truncated multibyte sequence.
The
value is storable, breaks upper(), and blocks restore.

Mechanism, with file:line
-------------------------
contrib/pgcrypto/pgcrypto.c, pg_crypt(): the result is built with

cres = px_crypt(buf0, buf1, resbuf, PX_MAX_CRYPT);
...
res = cstring_to_text(cres);
PG_RETURN_TEXT_P(res);

and there is no encoding validation on that path. The string "verifymbstr"
does not appear anywhere in pgcrypto.c, on 18.3 or on master.

contrib/pgcrypto/crypt-des.c, the traditional-DES branch, copies a FIXED
two bytes of the setting into the output:

output[0] = setting[0];
output[1] = setting[1] ? setting[1] : output[0];

with no character-boundary awareness. Identical on master.

So the rule is deterministic rather than intermittent: for traditional DES
the output is invalid exactly when a multibyte character straddles byte
offset 2, i.e. begins at byte 0 or byte 1 of the setting. The same mechanism
exists at different cut points elsewhere — xdes copies 9 bytes
(strlcpy(output, setting, 10)), and crypt-md5.c re-emits the raw salt run
capped at 8 bytes.

For contrast, contrib/pgcrypto/pgp-pgsql.c DOES validate, at two sites:

pg_verifymbstr(VARDATA_ANY(res), VARSIZE_ANY_EXHDR(res), false);

Observed behaviour
------------------
Verified live on both versions, in a UTF8 database:
crypt('password', E'€A') -> e282555a6f49796a2f48792f63
convert_from(decode('e282','hex'),'UTF8') -> ERROR, 0xe2 0x82
INSERT into a text column -> succeeds, octet_length 13
upper(stored) -> ERROR, 0xe2 0x82 0x55
COPY out then COPY in -> ERROR, 0xe2 0x82 0x55, COPY t2 line 1

#2Andrey Rachitskiy
pl0h0yp1@gmail.com
In reply to: PG Bug reporting form (#1)
Re: BUG #19600: pgcrypto crypt() can return text with invalid encoding

Hi, Michael!

Big thanks for the steady flow of bug reports. Thanks for caring about
every detail. Really impressive productivity, keep it up!

The attached patch adds pg_verifymbstr() in pg_crypt(). A regress case
based on the report is included. Non-UTF8 buildfarm animals skip that
file.

пн, 3 авг. 2026 г. в 16:51, PG Bug reporting form <noreply@postgresql.org>:

The following bug has been logged on the website:

Bug reference: 19600
Logged by: Michael Malis
Email address: malis@pgrust.com
PostgreSQL version: 19beta1
Operating system: Debian
Description:

pgcrypto's crypt() copies bytes from the caller-supplied setting/salt
verbatim into its text result without regard to character boundaries. When
a
multibyte character straddles the fixed byte count that gets copied, the
result is a text value containing a truncated UTF-8 sequence.

That value is invalid in the database encoding. The server rejects the
exact
same bytes when they are offered as input, yet it accepts them from crypt()
and lets them into a table, after which upper() fails on the stored value
and
the row cannot be restored from a dump.

This is the same class that was already accepted and fixed for pgcrypto's
other text-returning functions. The thread "Encoding protection for
pgcrypto"
added pg_verifymbstr() to pgp_sym_decrypt_text and pgp_pub_decrypt_text on
the
stated principle that "by default the PostgreSQL TEXT type should have the
same encoding as the database encoding". crypt() is the same class and was
NOT covered. Unlike those functions it has no bytea counterpart, so the
escape
hatch offered in that thread ("use pgp_*_decrypt_bytea") does not exist
here.

Reproducer
----------
Needs pgcrypto and a UTF8 database. Nothing here requires superuser;
crypt()
is EXECUTE-able by PUBLIC.

CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- the input salt is VALID UTF-8: U+20AC then 'A' -> e2 82 ac 41
SELECT encode(E'€A'::bytea, 'hex');

-- crypt() returns text whose bytes are NOT valid UTF-8
SELECT encode(crypt('password', E'€A')::bytea, 'hex');
-- e282555a6f49796a2f48792f63 <-- begins e2 82 55

-- the server rejects those same bytes on input
SELECT convert_from(decode('e282', 'hex'), 'UTF8');
-- ERROR: invalid byte sequence for encoding "UTF8": 0xe2 0x82

-- yet the value stores fine ...
CREATE TEMP TABLE t (h text);
INSERT INTO t SELECT crypt('password', E'€A');
SELECT octet_length(h) FROM t; -- 13

-- ... and then breaks a core text function
SELECT upper(h) FROM t;
-- ERROR: invalid byte sequence for encoding "UTF8": 0xe2 0x82 0x55

-- and the row does not survive dump/restore
\copy t TO '/tmp/crypt_invalid.txt'
CREATE TEMP TABLE t2 (h text);
\copy t2 FROM '/tmp/crypt_invalid.txt'
-- ERROR: invalid byte sequence for encoding "UTF8": 0xe2 0x82 0x55
-- CONTEXT: COPY t2, line 1

Two controls, both of which pass, so this is the geometry and not an
artifact of the test:

-- the documented idiom cannot trigger it: gen_salt() emits ASCII only
SELECT convert_from(crypt('password', gen_salt('des'))::bytea, 'UTF8')
IS NOT NULL; -- t

-- a multibyte character PAST the 2-byte cut is fine
SELECT convert_from(crypt('password', E'AB€')::bytea, 'UTF8')
IS NOT NULL; -- t

Expected vs. actual
-------------------
- Expected: crypt() either rejects a setting it cannot represent in the
database encoding, or returns a value that is valid in that encoding —
the same contract pgp_sym_decrypt_text and pgp_pub_decrypt_text now
honour.
- Actual: crypt() returns text containing a truncated multibyte sequence.
The
value is storable, breaks upper(), and blocks restore.

Mechanism, with file:line
-------------------------
contrib/pgcrypto/pgcrypto.c, pg_crypt(): the result is built with

cres = px_crypt(buf0, buf1, resbuf, PX_MAX_CRYPT);
...
res = cstring_to_text(cres);
PG_RETURN_TEXT_P(res);

and there is no encoding validation on that path. The string "verifymbstr"
does not appear anywhere in pgcrypto.c, on 18.3 or on master.

contrib/pgcrypto/crypt-des.c, the traditional-DES branch, copies a FIXED
two bytes of the setting into the output:

output[0] = setting[0];
output[1] = setting[1] ? setting[1] : output[0];

with no character-boundary awareness. Identical on master.

So the rule is deterministic rather than intermittent: for traditional DES
the output is invalid exactly when a multibyte character straddles byte
offset 2, i.e. begins at byte 0 or byte 1 of the setting. The same
mechanism
exists at different cut points elsewhere — xdes copies 9 bytes
(strlcpy(output, setting, 10)), and crypt-md5.c re-emits the raw salt run
capped at 8 bytes.

For contrast, contrib/pgcrypto/pgp-pgsql.c DOES validate, at two sites:

pg_verifymbstr(VARDATA_ANY(res), VARSIZE_ANY_EXHDR(res), false);

Observed behaviour
------------------
Verified live on both versions, in a UTF8 database:
crypt('password', E'€A') -> e282555a6f49796a2f48792f63
convert_from(decode('e282','hex'),'UTF8') -> ERROR, 0xe2 0x82
INSERT into a text column -> succeeds, octet_length 13
upper(stored) -> ERROR, 0xe2 0x82 0x55
COPY out then COPY in -> ERROR, 0xe2 0x82 0x55, COPY t2 line 1

--
Regards,
Rachitskiy Andrey

Attachments:

t253293_2
0001-pgcrypto-Reject-crypt-results-invalid-in-database-encoding-BUG-19600.patchtext/x-patch; charset=US-ASCII; name=0001-pgcrypto-Reject-crypt-results-invalid-in-database-encoding-BUG-19600.patchDownload+25-2
#3Ishjaap Singh
ishjaap.singh07@gmail.com
In reply to: Andrey Rachitskiy (#2)
Re: BUG #19600: pgcrypto crypt() can return text with invalid encoding

On Mon, Aug 3, 2026 at 15:26, Andrey Rachitskiy wrote:

The attached patch adds pg_verifymbstr() in pg_crypt().

Hi Andrey,

I reviewed and tested the patch on master (700b422af7e), built with
--with-ssl=openssl.

Without the patch, in a UTF8 database, I could reproduce the DES case
from the report, and also the xDES and MD5 cases it describes: a salt
whose multibyte character straddles the copied prefix ("€A" for DES,
"_J9..ab€" for xDES, "$1$1234567€" for MD5) makes crypt() return
invalid UTF-8, and a table holding the DES result can't be reloaded
with COPY. DES also returns invalid text in an EUC_JP database (salt
"A" followed by a two-byte character). SHA-crypt and Blowfish already
reject non-ASCII salts.

With the patch, each of those cut-off cases raises "invalid byte
sequence for encoding", and the ASCII and valid multibyte salts I tried
return the same hashes as before, which matches the code: the new check
only reads the result after it has been built. The pgcrypto tests pass
with UTF8, LATIN1 and SQL_ASCII databases (crypt-utf8 runs in the first
and is skipped in the others), and crypt-utf8 fails without the code
change.

Two points:

1. Hashes already stored with invalid encoding stop working after the
upgrade: crypt(pw, stored_hash) now raises an error instead of
returning a hash to compare, for the right password as well as a
wrong one. That seems acceptable to me, since the stored value
already can't be dumped and restored, and it's a similar trade-off
to the one the PGP decrypt functions made in d536aee5566, although
unlike those, crypt() has no bytea variant to fall back on. It may
be worth a mention in the release notes.

2. The new test covers only DES. Since the fix handles xDES and MD5 as
well, it might be worth adding those cases from the report.

For the back branches: the patch applies as-is to REL_18_STABLE and
REL_19_STABLE. On REL_14 to REL_17 the pgcrypto.c change and the new
test files apply cleanly; only the REGRESS list in the Makefile needs
adjusting, and REL_14/REL_15 have no meson.build. These were
applicability checks only; I didn't build the back branches.

Otherwise this looks good to me.

Thanks,
Ishjaap Singh