Secure database engineering
Lab: attack your own database
The idea in 30 seconds
Play the attacker against a copy of the shop database. Inject SQL as the table owner and as a least-privilege role, hijack a SECURITY DEFINER function with nothing more than a temporary table, and watch row-level security switch itself off for the table owner and for superusers. Then apply the fix for each and attack again.
Helps to know first: PostgreSQL security and multi-tenancy (coming soon), Functions, procedures and triggers, Connection management and pooling (coming soon)
On this page
Before you start
This lab changes table ownership, so run it in a separate database loaded with the shop dataset, not the one you use for the other topics:
createdb sec
psql -d sec -f shop.sql
Connect to sec as a superuser and create three roles: an owner nobody logs in as, the application with ordinary grants, and a careless application login that is a member of the owner role.
CREATE ROLE shop_owner NOLOGIN;
ALTER TABLE customers OWNER TO shop_owner;
ALTER TABLE orders OWNER TO shop_owner;
ALTER TABLE products OWNER TO shop_owner;
ALTER TABLE order_items OWNER TO shop_owner;
CREATE ROLE app LOGIN;
GRANT USAGE ON SCHEMA public TO app;
GRANT SELECT, INSERT, UPDATE, DELETE ON customers, orders, order_items, products TO app;
CREATE ROLE app_owner LOGIN IN ROLE shop_owner;
Results below are from PostgreSQL 18.3. On a local install these logins need no password; on anything shared, give them one.
1. What a fresh role can do
Connect as app (psql -d sec -U app) and check what it got without being granted anything beyond the table privileges:
SELECT has_database_privilege('sec', 'TEMP') AS temp_tables,
has_schema_privilege('public', 'CREATE') AS create_in_public;
SELECT count(*) AS functions_app_can_execute
FROM pg_proc WHERE has_function_privilege(oid, 'EXECUTE');
| Ours | Yours | |
|---|---|---|
| Can create temporary tables | true | |
Can create objects in public | false | |
| Functions it can execute | 3,347 |
Remember the first line. It is all step 3 needs.
2. Injection, as owner and as app
The endpoint glues the user’s input into SELECT id, name FROM customers WHERE email = '<input>'. Paste what it would send, first as app_owner, then as app, with each malicious input:
-- input: x'; DROP TABLE order_items; --
SELECT id, name FROM customers WHERE email = 'x'; DROP TABLE order_items; --';
-- input: x' OR true --
SELECT id, name FROM customers WHERE email = 'x' OR true --';
As app_owner | As app | Yours | |
|---|---|---|---|
DROP TABLE input | DROP TABLE succeeded | ERROR: must be owner of table order_items | |
OR true input | 8 rows | 8 rows |
If you dropped order_items, reload the dataset (psql -d sec -f shop.sql) and redo the ownership statements from the setup.
Now send the same inputs as parameters:
SELECT id, name FROM customers WHERE email = $1 \bind 'x'' OR true --' \g
SELECT id, name FROM customers WHERE email = $1 \bind 'x''; DROP TABLE orders; --' \g
Both return 0 rows and drop nothing: the input arrived as a value to compare with.
3. Hijack a SECURITY DEFINER function
Finance wants some users to approve refunds without giving them UPDATE on orders. As the superuser, create a list of approvers and a function, owned by shop_owner, that checks the list and then cancels the order:
CREATE TABLE refund_approvers (username text PRIMARY KEY);
INSERT INTO refund_approvers VALUES ('finance_lead');
ALTER TABLE refund_approvers OWNER TO shop_owner;
CREATE FUNCTION approve_refund(p_order int) RETURNS text
LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM refund_approvers WHERE username = session_user) THEN
RAISE EXCEPTION '% may not approve refunds', session_user;
END IF;
UPDATE orders SET status = 'cancelled' WHERE id = p_order;
RETURN 'refund approved for order ' || p_order;
END $$;
ALTER FUNCTION approve_refund(int) OWNER TO shop_owner;
As app, try it:
SELECT approve_refund(101);
ERROR: app may not approve refunds
Now attack. The function says refund_approvers without a schema, and the temporary schema is searched first for table names unless the search path says otherwise:
CREATE TEMP TABLE refund_approvers (username text);
INSERT INTO refund_approvers VALUES ('app');
GRANT SELECT ON refund_approvers TO PUBLIC; -- so the function's owner can read it
SELECT approve_refund(101);
approve_refund
-------------------------------
refund approved for order 101
Order 101 is now cancelled. The function ran with shop_owner’s privileges and checked the attacker’s list. Fix it as the superuser, with the pattern the documentation gives: trusted schemas first, pg_temp last.
UPDATE orders SET status = 'shipped' WHERE id = 101;
ALTER FUNCTION approve_refund(int) SET search_path = public, pg_temp;
Repeat the attack in a new app session. It fails with app may not approve refunds, and order 101 stays shipped.
4. Row-level security and who it ignores
As the superuser, make a table shared by three tenants, give it to shop_owner, and turn on a policy that shows each session only its own tenant’s rows:
CREATE TABLE invoices (id serial PRIMARY KEY, tenant_id int NOT NULL, amount numeric NOT NULL);
INSERT INTO invoices (tenant_id, amount) SELECT 1 + g % 3, g * 10 FROM generate_series(1, 9) g;
ALTER TABLE invoices OWNER TO shop_owner;
GRANT SELECT, INSERT, UPDATE, DELETE ON invoices TO app;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::int);
Run this as app, as app_owner, and as the superuser:
SET app.tenant_id = '2';
SELECT tenant_id, count(*) FROM invoices GROUP BY 1 ORDER BY 1;
Then, as the superuser, ALTER TABLE invoices FORCE ROW LEVEL SECURITY; and run it again for each login.
| Login | Tenants visible | After FORCE | Yours |
|---|---|---|---|
app | 2 only | 2 only | |
app_owner (member of the owner role) | 1, 2 and 3 | 2 only | |
| Superuser | 1, 2 and 3 | 1, 2 and 3 |
Nothing warned that the policy was being ignored. The owner skips row security unless the table is forced; superusers and roles with BYPASSRLS skip it always, FORCE or not. An application that connects as either sees every tenant.
What you found
- Least privilege turned
DROP TABLEinto an error, but theOR trueinjection read every customer either way. Parameters stopped both. - One temporary table was enough to make a privileged function trust the attacker.
SET search_path = ..., pg_tempclosed it. - Row-level security silently did nothing for the owner until
FORCE, and nothing for superusers at all.
Clean up
dropdb sec
DROP ROLE app, app_owner, shop_owner;
Check yourself
Why did the temporary table work as an attack, when
appcannot create anything inpublic?Show answer
Every role can create temporary tables by default, and the temporary schema is searched first for table names unless
search_pathlists it explicitly. The function’s unqualifiedrefund_approversfound the attacker’s temporary table before the real one.Why does
SET search_path = public, pg_tempon the function fix it?Show answer
It fixes the search path for the function’s whole execution, whatever the caller has set, and puts the temporary schema last, so the real
public.refund_approversis found first.A team enables row-level security and their tests pass, but in production every tenant sees every invoice. Name two likely causes from this lab.
Show answer
The application connects as the table’s owner (or a member of the owning role) and the table is not
FORCEd; or it connects as a superuser or a role withBYPASSRLS, which no setting on the table can override.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.PostgreSQL documentation: 5.8 PrivilegesPrimary· §5.8 Privileges
The right to drop or alter an object belongs to its owner and members of the owning role; PUBLIC gets TEMPORARY on databases by default.
- 2.PostgreSQL documentation: CREATE FUNCTIONPrimary· Writing SECURITY DEFINER Functions Safely
The temporary-table schema is searched first by default and is writable by anyone; a SECURITY DEFINER function should set search_path with pg_temp last.
- 3.PostgreSQL documentation: 19.11 Client Connection DefaultsPrimary· §19.11.1 Statement Behavior: search_path
If pg_temp is not listed in search_path it is searched first, for tables and other relations but never for functions or operators.
- 4.PostgreSQL documentation: 5.9 Row Security PoliciesPrimary· §5.9 Row Security Policies
Superusers and roles with BYPASSRLS always bypass row security; table owners bypass it unless the table has FORCE ROW LEVEL SECURITY.
- 5.PostgreSQL documentation: CREATE POLICYPrimary· Description
A USING expression decides which existing rows are visible.
- 6.PostgreSQL documentation: psqlPrimary· Meta-Commands: \bind
\bind sends the query with the given values as parameters.
Go further
What to read next, and why it is worth the time.
- 1.PostgreSQL documentation: 5.9 Row Security PoliciesPrimary· §5.9
Short, with worked examples, and the bypass rules this lab demonstrates are stated in its first paragraphs.
- 2.PostgreSQL documentation: CREATE FUNCTIONPrimary· Writing SECURITY DEFINER Functions Safely
The canonical safe pattern, in one example. Copy it every time you write a SECURITY DEFINER function.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.