From 693eb96b2f08644da58a9e60a8f0da49dedf464f Mon Sep 17 00:00:00 2001 From: Mehmet Ince Date: Mon, 3 Aug 2026 10:18:25 +0300 Subject: [PATCH 1/2] Fully qualify remaining references in extension scripts PostgreSQL replaces the caller's search_path while running an extension script, setting it to the extension's target schema. For a relocatable extension installed the usual way that schema is public, so any object a low-privileged user can create there is visible to the script, which runs as the superuser performing CREATE EXTENSION or ALTER EXTENSION. pg_catalog is searched implicitly ahead of the rest of the path, so a planted function with a signature identical to a catalog one loses. An exact-typed overload competing against a polymorphic catalog function does not: function resolution prefers the exact match before it ever considers schema order. A user with CREATE on public can therefore define public.unnest(text[]) and have it called in place of pg_catalog.unnest(anyarray), executing an arbitrary body as superuser while the install completes without error. ip4r--2.4.sql was exploitable on any install against PostgreSQL 11 or later via the UNNEST(ARRAY[...]) in the operator family fixups. The 2.2--2.4 update script was exploitable through UNNEST over its oid[] and text[] locals, and through the polymorphic array operators || and <>. The 2.0--2.1 and 2.1--2.2 scripts were exploitable through array_to_string over an oid[]. Qualify the function calls, replace the two vulnerable operators with OPERATOR(pg_catalog.<>) and pg_catalog.array_append(), and qualify the catalog table references while here. This completes the sweep started in b07bf1d, which qualified the format() calls only. Left unqualified references such as current_setting() and pg_get_indexdef() are not hijackable, since their catalog signatures are exact and pg_catalog wins ties, but they are qualified too so the scripts can be audited by inspection rather than by case analysis. Co-Authored-By: Claude Fable 5 --- scripts/ip4r--2.0--2.1.sql | 6 ++--- scripts/ip4r--2.1--2.2.sql | 2 +- scripts/ip4r--2.2--2.4.sql | 48 +++++++++++++++++++------------------- scripts/ip4r--2.4.sql | 4 ++-- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/scripts/ip4r--2.0--2.1.sql b/scripts/ip4r--2.0--2.1.sql index 8a254cd..41e5c20 100644 --- a/scripts/ip4r--2.0--2.1.sql +++ b/scripts/ip4r--2.0--2.1.sql @@ -21,7 +21,7 @@ WITH v(gname,gtype) AS ( ('gipr_consistent'::name, 'iprange'::regtype)) UPDATE pg_catalog.pg_proc SET pronargs = 5, - proargtypes = array_to_string(array['internal', + proargtypes = pg_catalog.array_to_string(array['internal', v.gtype, 'int2', 'oid', @@ -39,7 +39,7 @@ CREATE FUNCTION gipr_fetch(internal) RETURNS internal AS 'MODULE_PATHNAME' LANG DO $s$ BEGIN - IF current_setting('server_version_num')::integer >= 90500 THEN + IF pg_catalog.current_setting('server_version_num')::integer >= 90500 THEN ALTER OPERATOR FAMILY gist_ip4r_ops USING gist ADD FUNCTION 9 (ip4r,ip4r) gip4r_fetch (internal); ALTER OPERATOR FAMILY gist_ip6r_ops USING gist ADD @@ -47,7 +47,7 @@ DO $s$ ALTER OPERATOR FAMILY gist_iprange_ops USING gist ADD FUNCTION 9 (iprange,iprange) gipr_fetch (internal); END IF; - IF current_setting('server_version_num')::integer >= 90600 THEN + IF pg_catalog.current_setting('server_version_num')::integer >= 90600 THEN DECLARE r record; BEGIN diff --git a/scripts/ip4r--2.1--2.2.sql b/scripts/ip4r--2.1--2.2.sql index 36d4090..276d258 100644 --- a/scripts/ip4r--2.1--2.2.sql +++ b/scripts/ip4r--2.1--2.2.sql @@ -12,7 +12,7 @@ WITH v(gname,gtype) AS ( ('gipr_consistent'::name, 'iprange'::regtype)) UPDATE pg_catalog.pg_proc SET pronargs = 5, - proargtypes = array_to_string(array['internal', + proargtypes = pg_catalog.array_to_string(array['internal', v.gtype, 'int2', 'oid', diff --git a/scripts/ip4r--2.2--2.4.sql b/scripts/ip4r--2.2--2.4.sql index c1d0c7d..d1dcffa 100644 --- a/scripts/ip4r--2.2--2.4.sql +++ b/scripts/ip4r--2.2--2.4.sql @@ -78,7 +78,7 @@ DO $s$ cfgval text := NULL; BEGIN BEGIN - cfgval := current_setting('ip4r.update_indexes'); + cfgval := pg_catalog.current_setting('ip4r.update_indexes'); EXCEPTION WHEN OTHERS THEN cfgval := NULL; END; @@ -86,14 +86,14 @@ DO $s$ cfgval := NULL; END IF; SELECT oid INTO opc_oid - FROM pg_opclass + FROM pg_catalog.pg_opclass WHERE opcname='hash_iprange_ops' - AND pg_opclass_is_visible(oid); + AND pg_catalog.pg_opclass_is_visible(oid); -- first check for unknown/unexpected dependencies SELECT pg_catalog.format('%s object %s depends on hash_iprange_ops with type ''%s''', classid::regclass, objid, deptype) INTO str - FROM pg_depend + FROM pg_catalog.pg_depend WHERE refclassid = 'pg_opclass'::regclass AND refobjid = opc_oid AND classid <> 'pg_class'::regclass @@ -105,16 +105,16 @@ DO $s$ DETAIL = str; END IF; -- find all dependent tables and indexes - deps := ARRAY(SELECT objid FROM pg_depend + deps := ARRAY(SELECT objid FROM pg_catalog.pg_depend WHERE refclassid = 'pg_opclass'::regclass AND refobjid = opc_oid AND classid = 'pg_class'::regclass AND deptype = 'n'); - IF deps <> '{}' THEN + IF deps OPERATOR(pg_catalog.<>) '{}' THEN -- we don't expect to find anything except indexes SELECT pg_catalog.format('Table %s depends on hash_iprange_ops', oid::regclass) INTO str - FROM pg_class + FROM pg_catalog.pg_class WHERE oid = ANY (deps) AND relkind <> 'i'; IF FOUND THEN @@ -127,10 +127,10 @@ DO $s$ FOR r IN SELECT ci.relname as indexname, n.nspname as schemaname, ct.relname as tablename - FROM pg_index i - JOIN pg_class ci ON ci.oid=i.indexrelid - JOIN pg_class ct ON ct.oid=i.indrelid - JOIN pg_namespace n ON n.oid=ct.relnamespace + FROM pg_catalog.pg_index i + JOIN pg_catalog.pg_class ci ON ci.oid=i.indexrelid + JOIN pg_catalog.pg_class ct ON ct.oid=i.indrelid + JOIN pg_catalog.pg_namespace n ON n.oid=ct.relnamespace WHERE i.indexrelid = ANY (deps) LOOP RAISE INFO USING @@ -149,26 +149,26 @@ DO $s$ n.nspname as schemaname, ct.relname as tablename, ci.relname as indexname, - pg_get_indexdef(ci.oid) as command - FROM pg_index i - JOIN pg_class ci ON ci.oid=i.indexrelid - JOIN pg_class ct ON ct.oid=i.indrelid - JOIN pg_namespace n ON n.oid=ct.relnamespace + pg_catalog.pg_get_indexdef(ci.oid) as command + FROM pg_catalog.pg_index i + JOIN pg_catalog.pg_class ci ON ci.oid=i.indexrelid + JOIN pg_catalog.pg_class ct ON ct.oid=i.indrelid + JOIN pg_catalog.pg_namespace n ON n.oid=ct.relnamespace WHERE i.indexrelid = ANY (deps); ALTER EXTENSION ip4r DROP TABLE ip4r_update_to_2_4.update_indexes; FOR r IN SELECT pg_catalog.format('DROP INDEX %s RESTRICT', o::regclass) as cmd - FROM UNNEST(deps) u(o) LOOP + FROM pg_catalog.unnest(deps) u(o) LOOP RAISE INFO 'executing %', r.cmd; EXECUTE r.cmd; END LOOP; ELSIF cfgval = 'rebuild' THEN cmds := '{}'; FOR r IN SELECT pg_catalog.format('DROP INDEX %s RESTRICT', o::regclass) as dropcmd, - pg_get_indexdef(o) as createcmd - FROM UNNEST(deps) u(o) LOOP + pg_catalog.pg_get_indexdef(o) as createcmd + FROM pg_catalog.unnest(deps) u(o) LOOP RAISE INFO 'executing %', r.dropcmd; EXECUTE r.dropcmd; - cmds := cmds || r.createcmd; + cmds := pg_catalog.array_append(cmds, r.createcmd); END LOOP; END IF; END IF; @@ -181,14 +181,14 @@ DO $s$ OPERATOR 1 = , FUNCTION 1 iprange_hash(iprange); IF cmds IS NOT NULL THEN - FOR r IN SELECT cmd FROM UNNEST(cmds) u(cmd) LOOP + FOR r IN SELECT cmd FROM pg_catalog.unnest(cmds) u(cmd) LOOP -- we rely here on CREATE INDEX not recording a dependency on -- the extension RAISE INFO 'executing %', r.cmd; EXECUTE r.cmd; END LOOP; RAISE INFO 'index rebuilds completed'; - ELSIF cfgval = 'drop' AND deps <> '{}' THEN + ELSIF cfgval = 'drop' AND deps OPERATOR(pg_catalog.<>) '{}' THEN RAISE LOG 'table ip4r_update_to_2_4.update_indexes was created'; RAISE INFO 'table ip4r_update_to_2_4.update_indexes was created'; END IF; @@ -197,7 +197,7 @@ $s$; DO $s$ DECLARE - pg_ver integer := current_setting('server_version_num')::integer; + pg_ver integer := pg_catalog.current_setting('server_version_num')::integer; r record; BEGIN IF pg_ver >= 90600 THEN @@ -215,7 +215,7 @@ DO $s$ END IF; IF pg_ver >= 110000 THEN FOR r IN SELECT tname - FROM UNNEST(ARRAY['ip4','ip4r', + FROM pg_catalog.unnest(ARRAY['ip4','ip4r', 'ip6','ip6r', 'ipaddress','iprange']) u(tname) LOOP diff --git a/scripts/ip4r--2.4.sql b/scripts/ip4r--2.4.sql index 37a3e84..1d98ea4 100644 --- a/scripts/ip4r--2.4.sql +++ b/scripts/ip4r--2.4.sql @@ -821,7 +821,7 @@ CREATE OPERATOR CLASS gist_iprange_ops DEFAULT FOR TYPE iprange USING gist AS DO $s$ DECLARE - pg_ver integer := current_setting('server_version_num')::integer; + pg_ver integer := pg_catalog.current_setting('server_version_num')::integer; r record; BEGIN IF pg_ver >= 90500 THEN @@ -847,7 +847,7 @@ DO $s$ END IF; IF pg_ver >= 110000 THEN FOR r IN SELECT tname - FROM UNNEST(ARRAY['ip4','ip4r', + FROM pg_catalog.unnest(ARRAY['ip4','ip4r', 'ip6','ip6r', 'ipaddress','iprange']) u(tname) LOOP From d28cd7d7546f6129ba2d86fb1691f628bb9775eb Mon Sep 17 00:00:00 2001 From: Mehmet Ince Date: Mon, 3 Aug 2026 10:59:50 +0300 Subject: [PATCH 2/2] Qualify oid-vs-reg* comparisons in extension scripts too The previous commit qualified function calls and the two polymorphic array operators, but missed a second instance of the same hazard. Where a catalog column of type oid is compared against a ::regclass or ::regtype constant, pg_catalog has no operator for that argument pair, so a planted public.=(oid,regclass) is the only exact match and wins outright. Unlike the unknown-literal comparisons elsewhere in these scripts -- name = 'x', "char" <> 'i' and so on, where the catalog's own operator takes the tie -- there is nothing for it to tie against. The reachable case is the dependency scan in ip4r--2.2--2.4.sql. With a dependent hash index present, which is the situation the whole update script exists to handle, a planted =(oid,regclass) is evaluated and its body runs as the superuser performing ALTER EXTENSION. Confirmed by escalating a low-privileged role to superuser on 18.4. Use OPERATOR(pg_catalog.=) and OPERATOR(pg_catalog.<>) there. The IN (...::regprocedure) lists are not capturable -- an IN list of two or more elements becomes = ANY over an array and resolves to oid = oid -- but that depends on subtle resolution behaviour, so compare oid to oid explicitly and remove the question. The row comparison against ::regtype in ip4r--2.0--2.1.sql is likewise not an escalation route, because a planted operator outside a btree family makes the row comparison fail to resolve, but that is an unauthenticated denial of service on the update, so cast there as well. Also restore the continuation-line alignment disturbed by the previous commit, where qualifying a call shifted the opening element right but left the following lines behind. Co-Authored-By: Claude Fable 5 --- scripts/ip4r--2.0--2.1.sql | 26 +++++++++++++------------- scripts/ip4r--2.1--2.2.sql | 10 +++++----- scripts/ip4r--2.2--2.4.sql | 20 ++++++++++---------- scripts/ip4r--2.4.sql | 12 ++++++------ 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/scripts/ip4r--2.0--2.1.sql b/scripts/ip4r--2.0--2.1.sql index 41e5c20..2cce51e 100644 --- a/scripts/ip4r--2.0--2.1.sql +++ b/scripts/ip4r--2.0--2.1.sql @@ -7,10 +7,10 @@ UPDATE pg_catalog.pg_cast SET castcontext = 'a' WHERE (castsource,casttarget) IN ( - ('ipaddress'::regtype, 'ip4'::regtype), - ('ipaddress'::regtype, 'ip6'::regtype), - ('iprange'::regtype, 'ip4r'::regtype), - ('iprange'::regtype, 'ip6r'::regtype)); + ('ipaddress'::regtype::oid, 'ip4'::regtype::oid), + ('ipaddress'::regtype::oid, 'ip6'::regtype::oid), + ('iprange'::regtype::oid, 'ip4r'::regtype::oid), + ('iprange'::regtype::oid, 'ip6r'::regtype::oid)); -- double ugh, to finally fix long-standing issue with function signature -- of gist consistent functions @@ -22,11 +22,11 @@ WITH v(gname,gtype) AS ( UPDATE pg_catalog.pg_proc SET pronargs = 5, proargtypes = pg_catalog.array_to_string(array['internal', - v.gtype, - 'int2', - 'oid', - 'internal']::regtype[]::oid[], - ' ')::pg_catalog.oidvector + v.gtype, + 'int2', + 'oid', + 'internal']::regtype[]::oid[], + ' ')::pg_catalog.oidvector FROM v WHERE proname = v.gname AND probin = 'MODULE_PATHNAME'; @@ -55,10 +55,10 @@ DO $s$ FROM pg_catalog.pg_proc WHERE (probin = 'MODULE_PATHNAME' AND prolang = (SELECT oid FROM pg_catalog.pg_language l WHERE l.lanname='c')) - OR (oid in ('family(ip4)'::regprocedure, - 'family(ip6)'::regprocedure, - 'family(ip4r)'::regprocedure, - 'family(ip6r)'::regprocedure)) + OR (oid in ('family(ip4)'::regprocedure::oid, + 'family(ip6)'::regprocedure::oid, + 'family(ip4r)'::regprocedure::oid, + 'family(ip6r)'::regprocedure::oid)) LOOP EXECUTE pg_catalog.format('ALTER FUNCTION %s PARALLEL SAFE', r.fsig); END LOOP; diff --git a/scripts/ip4r--2.1--2.2.sql b/scripts/ip4r--2.1--2.2.sql index 276d258..cac9482 100644 --- a/scripts/ip4r--2.1--2.2.sql +++ b/scripts/ip4r--2.1--2.2.sql @@ -13,11 +13,11 @@ WITH v(gname,gtype) AS ( UPDATE pg_catalog.pg_proc SET pronargs = 5, proargtypes = pg_catalog.array_to_string(array['internal', - v.gtype, - 'int2', - 'oid', - 'internal']::regtype[]::oid[], - ' ')::pg_catalog.oidvector + v.gtype, + 'int2', + 'oid', + 'internal']::regtype[]::oid[], + ' ')::pg_catalog.oidvector FROM v WHERE proname = v.gname AND probin = 'MODULE_PATHNAME'; diff --git a/scripts/ip4r--2.2--2.4.sql b/scripts/ip4r--2.2--2.4.sql index d1dcffa..69c9dc0 100644 --- a/scripts/ip4r--2.2--2.4.sql +++ b/scripts/ip4r--2.2--2.4.sql @@ -94,9 +94,9 @@ DO $s$ classid::regclass, objid, deptype) INTO str FROM pg_catalog.pg_depend - WHERE refclassid = 'pg_opclass'::regclass + WHERE refclassid OPERATOR(pg_catalog.=) 'pg_opclass'::regclass AND refobjid = opc_oid - AND classid <> 'pg_class'::regclass + AND classid OPERATOR(pg_catalog.<>) 'pg_class'::regclass AND deptype NOT IN ('a','i') LIMIT 1; IF FOUND THEN @@ -106,9 +106,9 @@ DO $s$ END IF; -- find all dependent tables and indexes deps := ARRAY(SELECT objid FROM pg_catalog.pg_depend - WHERE refclassid = 'pg_opclass'::regclass + WHERE refclassid OPERATOR(pg_catalog.=) 'pg_opclass'::regclass AND refobjid = opc_oid - AND classid = 'pg_class'::regclass + AND classid OPERATOR(pg_catalog.=) 'pg_class'::regclass AND deptype = 'n'); IF deps OPERATOR(pg_catalog.<>) '{}' THEN -- we don't expect to find anything except indexes @@ -205,10 +205,10 @@ DO $s$ FROM pg_catalog.pg_proc WHERE (probin = 'MODULE_PATHNAME' AND prolang = (SELECT oid FROM pg_catalog.pg_language l WHERE l.lanname='c')) - OR (oid in ('family(ip4)'::regprocedure, - 'family(ip6)'::regprocedure, - 'family(ip4r)'::regprocedure, - 'family(ip6r)'::regprocedure)) + OR (oid in ('family(ip4)'::regprocedure::oid, + 'family(ip6)'::regprocedure::oid, + 'family(ip4r)'::regprocedure::oid, + 'family(ip6r)'::regprocedure::oid)) LOOP EXECUTE pg_catalog.format('ALTER FUNCTION %s PARALLEL SAFE', r.fsig); END LOOP; @@ -216,8 +216,8 @@ DO $s$ IF pg_ver >= 110000 THEN FOR r IN SELECT tname FROM pg_catalog.unnest(ARRAY['ip4','ip4r', - 'ip6','ip6r', - 'ipaddress','iprange']) u(tname) + 'ip6','ip6r', + 'ipaddress','iprange']) u(tname) LOOP EXECUTE pg_catalog.format('ALTER OPERATOR FAMILY %I USING hash' ' ADD FUNCTION 2 %I(%I,bigint)', diff --git a/scripts/ip4r--2.4.sql b/scripts/ip4r--2.4.sql index 1d98ea4..4c11c11 100644 --- a/scripts/ip4r--2.4.sql +++ b/scripts/ip4r--2.4.sql @@ -837,10 +837,10 @@ DO $s$ FROM pg_catalog.pg_proc WHERE (probin = 'MODULE_PATHNAME' AND prolang = (SELECT oid FROM pg_catalog.pg_language l WHERE l.lanname='c')) - OR (oid in ('family(ip4)'::regprocedure, - 'family(ip6)'::regprocedure, - 'family(ip4r)'::regprocedure, - 'family(ip6r)'::regprocedure)) + OR (oid in ('family(ip4)'::regprocedure::oid, + 'family(ip6)'::regprocedure::oid, + 'family(ip4r)'::regprocedure::oid, + 'family(ip6r)'::regprocedure::oid)) LOOP EXECUTE pg_catalog.format('ALTER FUNCTION %s PARALLEL SAFE', r.fsig); END LOOP; @@ -848,8 +848,8 @@ DO $s$ IF pg_ver >= 110000 THEN FOR r IN SELECT tname FROM pg_catalog.unnest(ARRAY['ip4','ip4r', - 'ip6','ip6r', - 'ipaddress','iprange']) u(tname) + 'ip6','ip6r', + 'ipaddress','iprange']) u(tname) LOOP EXECUTE pg_catalog.format('ALTER OPERATOR FAMILY %I USING hash' ' ADD FUNCTION 2 %I(%I,bigint)',