ยอดรวมค้างอยู่ที่สามทุกสถานะ แต่สามแถวนั้นเปลี่ยนเจ้าของไปแล้ว
ห้าบทที่ผ่านมาวัดทุกอย่างด้วยจำนวนแถว และหน่วยนั้นทำงานได้ดีทุกครั้ง เพราะการรั่วในคอร์สนี้ไม่มีรหัสข้อผิดพลาดให้ดู มีแต่แถวที่ผู้อ่านได้กลับไป
บทนี้วัดทรงที่หน่วยนั้นมองไม่เห็น app role ตั้ง app.tenant เป็น clinic-a
แล้วนับแถวได้ 3 ทั้งก่อนโจมตี ระหว่างโจมตี และหลังปิดช่อง
สามสถานะให้เลขเดียวกันหมด แต่ในสถานะกลาง แถวทั้งสามที่ได้กลับไป
ไม่มีแถวไหนเป็นของ clinic-a เลยสักแถว
คอร์สนี้อยู่ในซีรีส์คลินิกเดียวกับ คอร์สคัดกรองผู้เรียก และ คอร์ส CORS กับ CSRF แต่หกบทแรกไม่มีแอปมาเกี่ยวข้อง มีแต่ตาราง policy และ role
ตัวเลขทุกตัวในบทนี้คุณรันเองได้ สั่ง scripts/clinic-rls/up.sh ซึ่งยก container
ชื่อ kaen-rls-pg ขึ้นที่ port 55438 แล้วสั่ง python3 scripts/clinic-rls/spike.py
เก็บกวาดด้วย docker rm -f kaen-rls-pg
ห่วงโซ่ที่ policy ใบนี้เดินผ่าน
หัวข้อที่มีชื่อว่า “ห่วงโซ่ที่ policy ใบนี้เดินผ่าน”policy ไม่ได้เทียบค่าเอง มันเรียกฟังก์ชัน และฟังก์ชันนั้นเรียกฟังก์ชันอีกตัว
ทรงนี้ใช้ตาราง t_hijack ซึ่งหว่านด้วยเมล็ดก้อนเดียวกับบทที่ 1
คือหกแถว สามแถวต่อผู้เช่าหนึ่งราย ต่างจากบทก่อนหน้าตรงที่นิพจน์ของ policy
ไม่ได้เทียบ tenant_id กับค่าตั้งตรงๆ แต่ส่งต่อให้ฟังก์ชัน
create table t_hijack ( id bigserial primary key, tenant_id text not null, patient text not null, archived boolean not null default false);insert into t_hijack (tenant_id, patient, archived) values ('clinic-a','Ann',false),('clinic-a','Ben',false),('clinic-a','Cid',true), ('clinic-b','Dan',false),('clinic-b','Eve',false),('clinic-b','Fay',true);grant select, insert, update, delete on t_hijack to kaen_rls_app, kaen_rls_bypass;grant usage, select on sequence t_hijack_id_seq to kaen_rls_app, kaen_rls_bypass;alter table t_hijack enable row level security;create function public.guard() returns text language sql as $$ select current_setting('app.tenant', true) $$;create function public.tenant_ok(t text) returns boolean language plpgsql as $$ begin return t = guard(); end $$;create policy p on t_hijack for all using (public.tenant_ok(tenant_id));ห่วงโซ่มีสามข้อต่อ และข้อต่อที่สามคือที่ที่บทนี้ทำงาน
- policy เรียก
public.tenant_okซึ่งระบุ schema ไว้ในนิพจน์แล้ว tenant_okเป็น plpgsql และข้างในมันเรียกguard()แบบไม่ระบุ schemaguard()อ่านค่าตั้งของ session ด้วย current_settingcurrent_setting()ฟังก์ชันที่อ่านค่าของ GUC · ใช้ได้สองรูป · ทรงที่วัดคือ session เดียวที่อ่านตาราง t_guc · รูปที่ไม่ใส่อาร์กิวเมนต์ที่สอง ยกรหัส 42704 (csNoArgNeverSet) เมื่อไม่มีใครเคยตั้งค่านั้นใน session นี้ ส่วนหลัง RESET รูปเดียวกันได้ 00000 (csNoArgAfterReset) และค่าที่ได้เป็นสตริงยาว 0 (csTrueAfterResetLen) แล้วคืนค่านั้นออกมา
ชื่อที่ไม่ระบุ schema ต้องถูกค้นหา และตัวที่กำหนดลำดับการค้นคือ search_pathsearch_pathลำดับ schema ที่ PostgreSQL ใช้ค้นชื่อซึ่งไม่ได้ระบุ schema ไว้ · ฟังก์ชันของ policy ที่ไม่ได้ตรึงค่านี้ ทำให้แถวของตาราง t_hijack ที่ผู้อ่านเห็น ย้ายจากผู้เช่า a ไปเป็นของผู้เช่า b · ยอดของผู้เช่า b ขยับจาก 0 เป็น 3 แล้วกลับเป็น 0 หลังตรึงค่านี้ลงบนฟังก์ชัน ขณะที่ยอดรวมค้างอยู่ที่ 3 ทั้งสามสถานะ ของ session ที่กำลังเรียก ซึ่งในทรงนี้คือ session ของผู้อ่าน ไม่ใช่ของเจ้าของตาราง
ฟังก์ชันตัวกลางตรึง search_path ไว้หรือไม่
หัวข้อที่มีชื่อว่า “ฟังก์ชันตัวกลางตรึง search_path ไว้หรือไม่”อ่านค่านี้จาก
pg_procก่อนแตะอะไรทั้งสิ้น เพราะทั้งบทตั้งอยู่บนคำตอบของมัน
select 'KAEN|hijackFnHasSearchPath|'||(proconfig is not null)from pg_proc where oid = 'public.tenant_ok(text)'::regprocedure;hijackFnHasSearchPath = false
ค่านี้อ่านออกมาจาก pg_proc จริง ไม่ใช่สิ่งที่บทนี้เดาจาก DDL ข้างบน
false แปลว่าฟังก์ชันตัวนี้ไม่มีค่าตั้งของตัวเองติดมาสักตัว มันจึงใช้ search_path
ของผู้เรียกตอนที่ถูกเรียก
ฟังก์ชันที่เขียนแบบนี้อ่านแล้วไม่มีอะไรน่าสงสัย มันสั้น ทำงานอย่างเดียว
และไม่มีคำว่า search_path อยู่ในตัวมันเลย
ก่อนโจมตี · สามแถวของผู้เช่าตัวเอง
หัวข้อที่มีชื่อว่า “ก่อนโจมตี · สามแถวของผู้เช่าตัวเอง”ทรงตั้งต้นที่ทุกตัวเลขข้างหลังจะถูกเทียบกลับมาที่นี่
set app.tenant = 'clinic-a';select 'KAEN|hijackBaseTotal|'||count(*) from t_hijack where true;select 'KAEN|hijackBaseRowsB|'||count(*) from t_hijack where tenant_id='clinic-b';| คีย์ | ค่าที่วัดได้ |
|---|---|
hijackBaseTotal | 3 |
hijackBaseRowsB | 0 |
สองแถวนี้คือรูปที่ควรจะเป็น ผู้อ่านที่ตั้งตัวเองเป็น clinic-a เห็นสามแถว
และในสามแถวนั้นไม่มีแถวของ clinic-b ปนมาสักแถว
เมล็ดของตารางมีผู้เช่าสองรายเท่านั้น สามแถวที่นับได้จึงเป็นของ clinic-a ทั้งสามแถว
ข้อนี้เป็นการอ่านเลขสองตัวข้างบนประกอบกัน ไม่ใช่คีย์ตัวที่สาม
ความพยายามที่หนึ่ง · ทางที่ไม่ถึงปลาย
หัวข้อที่มีชื่อว่า “ความพยายามที่หนึ่ง · ทางที่ไม่ถึงปลาย”ผู้โจมตีสร้างฟังก์ชันชื่อเดียวกันใน
pg_tempแล้ววางมันไว้หน้าsearch_path
pg_temp คือ schema ชั่วคราวของ session ตัวเอง app role มีสิทธิ์สร้างของในนั้น
ตั้งแต่ต้นโดยไม่ต้องขอใคร ทางนี้จึงเป็นทางแรกที่ใครก็ตามจะลอง
set app.tenant = 'clinic-a';create temp table t_anchor (x int);create function pg_temp.guard() returns text language sql as $$ select 'clinic-b'::text $$;set search_path = pg_temp, public;select 'KAEN|hijackTempQualified|'||pg_temp.guard();select 'KAEN|hijackTempUnqualified|'||guard();select 'KAEN|hijackTempTotal|'||count(*) from t_hijack where true;select 'KAEN|hijackTempRowsB|'||count(*) from t_hijack where tenant_id='clinic-b';| คีย์ | ค่าที่วัดได้ |
|---|---|
hijackTempQualified | clinic-b |
hijackTempUnqualified | clinic-a |
hijackTempTotal | 3 |
hijackTempRowsB | 0 |
สองแถวบนคือหัวใจของทรงนี้ เรียกด้วยชื่อที่ระบุ schema ได้ clinic-b
ซึ่งยืนยันว่าฟังก์ชันของผู้โจมตีมีอยู่จริงและคืนค่าที่ตั้งใจไว้จริง
แต่ชื่อ guard() ที่ไม่ระบุ schema ยังได้ clinic-a ทั้งที่ pg_temp
อยู่หน้าสุดของ search_path แล้ว สองแถวล่างจึงไม่ขยับจากทรงตั้งต้นเลย
เอกสารของ PostgreSQL 18 อธิบายกฎข้อนี้ไว้เอง คือ schema ชั่วคราวถูกค้นสำหรับชื่อของ relation และชื่อของ data type แต่ไม่ถูกค้นสำหรับชื่อฟังก์ชันและชื่อ operator ข้อนี้เป็นข้ออ้างเรื่องกลไก จึงอ้างเอกสาร ส่วนสี่ค่าข้างบนอ้าง probe
ความพยายามที่สอง · schema ที่ app role เป็นเจ้าของ
หัวข้อที่มีชื่อว่า “ความพยายามที่สอง · schema ที่ app role เป็นเจ้าของ”ทางเดียวกันเป๊ะ เปลี่ยนแค่ schema ที่ฟังก์ชันไปนั่งอยู่ แล้วผลกลับด้าน
schema ที่ทรงนี้ใช้ชื่อ app_own และ DBA เป็นคนสร้างให้ ไม่ใช่ app role สร้างเอง
บรรทัดนี้อยู่ในขั้นตั้งฐานข้อมูลของ probe และผู้สั่งคือ postgres
create schema app_own authorization kaen_rls_app;set app.tenant = 'clinic-a';create function app_own.guard() returns text language sql as $$ select 'clinic-b'::text $$;set search_path = app_own, public;select 'KAEN|hijackOwnUnqualified|'||guard();select 'KAEN|hijackOwnTotal|'||count(*) from t_hijack where true;select 'KAEN|hijackOwnRowsA|'||count(*) from t_hijack where tenant_id='clinic-a';select 'KAEN|hijackOwnRowsB|'||count(*) from t_hijack where tenant_id='clinic-b';| คีย์ | ค่าที่วัดได้ |
|---|---|
hijackOwnUnqualified | clinic-b |
hijackOwnTotal | 3 |
hijackOwnRowsA | 0 |
hijackOwnRowsB | 3 |
แถวบนสุดคือความต่างทั้งหมดระหว่างสองความพยายาม ชื่อที่ไม่ระบุ schema ตัวเดียวกัน
เดินไปถึงฟังก์ชันของผู้โจมตีแล้ว และคืน clinic-b ออกมาให้ policy ใช้ตัดสิน
hijackOwnTotal = 3 เท่ากับ hijackBaseTotal = 3 เป๊ะ
แต่ hijackOwnRowsA = 0 และ hijackOwnRowsB = 3
คือทุกแถวที่ผู้อ่านได้กลับไป เป็นของผู้เช่าที่ไม่ใช่ตัวเอง
app.tenant ยังเป็น clinic-a ตลอดทรงนี้ ไม่มีใครแตะ policy ไม่มีใครแตะตาราง
และไม่มีคำสั่งไหนล้มเหลว สิ่งเดียวที่เปลี่ยนคือลำดับการค้นชื่อ
สองเงื่อนไขที่การโจมตีนี้ต้องการ
หัวข้อที่มีชื่อว่า “สองเงื่อนไขที่การโจมตีนี้ต้องการ”ทั้งสองข้อต้องเป็นจริงพร้อมกัน และข้อที่สองไม่ได้มาเอง
ข้อแรก ทาง pg_temp ใช้ไม่ได้ hijackTempUnqualified = clinic-a ปิดทางนั้นไปแล้ว
ผู้โจมตีที่มีแค่ schema ชั่วคราวของตัวเอง ไปไม่ถึงฟังก์ชันที่ policy เรียก
ข้อที่สอง ต้องมี schema ที่ app role เป็นเจ้าของ ในทรงนี้คือ app_own
ซึ่ง DBA สั่ง create schema ... authorization ให้ ตามบรรทัดที่ยกมาข้างบน
ถ้าอ่านเฉพาะตัวเลขของความพยายามที่สอง จะได้ข้อสรุปว่า search_path
ที่ไม่ถูกตรึงเปิดช่องให้ทุก role ที่ต่อฐานข้อมูลได้ ซึ่งไม่ใช่สิ่งที่ทรงนี้วัด
ถ้าอ่านเฉพาะตัวเลขของความพยายามที่หนึ่ง จะได้ข้อสรุปตรงข้ามว่าช่องนี้ปิดอยู่แล้ว ซึ่งก็ไม่ใช่สิ่งที่ทรงนี้วัดเช่นกัน คำตอบอยู่ที่การอ่านสองชุดคู่กัน
ทางปิด · ตรึง search_path ลงบนตัวฟังก์ชัน
หัวข้อที่มีชื่อว่า “ทางปิด · ตรึง search_path ลงบนตัวฟังก์ชัน”ผู้โจมตียัง
set search_pathชุดเดิม และฟังก์ชันของเขายังอยู่ที่เดิม
alter function public.tenant_ok(text) set search_path = pg_catalog, public;set app.tenant = 'clinic-a';set search_path = app_own, public;select 'KAEN|hijackPinnedTotal|'||count(*) from t_hijack where true;select 'KAEN|hijackPinnedRowsB|'||count(*) from t_hijack where tenant_id='clinic-b';| คีย์ | ค่าที่วัดได้ |
|---|---|
hijackPinnedTotal | 3 |
hijackPinnedRowsB | 0 |
app_own.guard ยังมีอยู่ และ search_path ของผู้อ่านยังชี้ไปที่ app_own ก่อน
แต่ค่าที่วัดได้กลับไปเท่ากับทรงตั้งต้นทั้งสองคีย์
คำสั่งที่ปิดช่องนี้เป็น ALTER FUNCTION และมันผูกค่าตั้งไว้กับตัวฟังก์ชัน
ฟังก์ชันจึงไม่ได้ใช้ search_path ของผู้เรียกอีกต่อไป เอกสารของ PostgreSQL 18
แนะนำวิธีนี้ไว้เองในหัวข้อการเขียนฟังก์ชันอย่างปลอดภัย
ส่วนชื่อ public.tenant_ok ที่นิพจน์ของ policy เรียกนั้น ระบุ schema ไว้อยู่แล้ว
และเอกสารเรื่อง dependency tracking อธิบายว่า PostgreSQL บันทึกการอ้างถึง object
ที่ปรากฏในนิพจน์ที่เก็บไว้ เป็นการอ้างถึงตัว object เอง ข้อนี้อ้างเอกสาร ไม่ได้อ้าง probe
สามสถานะ เลขเดียวกัน คนละความหมาย
หัวข้อที่มีชื่อว่า “สามสถานะ เลขเดียวกัน คนละความหมาย”ยอดรวมเป็น 3 ทั้งสี่แถวของตาราง และมีแถวเดียวที่ผิด
flowchart TB
Q["app role ตั้ง app.tenant เป็น clinic-a แล้วนับแถวใน t_hijack"] --> P["policy p เรียก public.tenant_ok ซึ่งระบุ schema ไว้ในนิพจน์"]
P --> F["ข้างใน tenant_ok เรียกชื่อ guard แบบไม่ระบุ schema"]
F --> S{"ฟังก์ชัน tenant_ok ตรึง search_path ไว้หรือยัง"}
S -->|"ตรึงแล้วด้วย alter function"| R["public.guard ถูกเรียก · 3 แถว · ของ b เป็น 0 · hijackPinnedTotal และ hijackPinnedRowsB"]
S -->|"ยัง · hijackFnHasSearchPath เป็น false"| U{"search_path ของผู้อ่านชี้ไปที่ schema ใดก่อน"}
U -->|"pg_temp แล้วค่อย public"| T["ชื่อฟังก์ชันข้าม pg_temp · public.guard ถูกเรียก · 3 แถว · ของ b เป็น 0"]
U -->|"app_own แล้วค่อย public"| A["app_own.guard ถูกเรียก · 3 แถว · ของ a เป็น 0 และของ b เป็น 3"]
คำบรรยายภาพ: ทางเดินของชื่อ guard ที่ไม่ระบุ schema เป็นตัวตัดสินว่าผู้อ่านได้แถวของใคร
ปลายทางทั้งสามใบเขียนว่าสามแถวเท่ากันหมด และมีใบเดียวที่สามแถวนั้นเป็นของผู้เช่าคนอื่น
| สถานะ | ยอดรวม | แถวของ clinic-b |
|---|---|---|
| ก่อนโจมตี | 3 · hijackBaseTotal | 0 · hijackBaseRowsB |
search_path = pg_temp, public | 3 · hijackTempTotal | 0 · hijackTempRowsB |
search_path = app_own, public | 3 · hijackOwnTotal | 3 · hijackOwnRowsB |
ตรึง search_path ลงบนฟังก์ชันแล้ว | 3 · hijackPinnedTotal | 0 · hijackPinnedRowsB |
ด่านที่เฝ้าแต่ยอดรวม ผ่านการโจมตีนี้ไปอย่างสะอาด
หัวข้อที่มีชื่อว่า “ด่านที่เฝ้าแต่ยอดรวม ผ่านการโจมตีนี้ไปอย่างสะอาด”หน่วยที่คอร์สนี้ใช้มาห้าบท มีจุดบอดหนึ่งจุด และจุดนั้นอยู่ในตารางข้างบน
สมมติมีด่านที่ยิงคำขอเข้ามาแล้วนับแถวที่ได้กลับไป ถ้าเลขเป็นสามก็ถือว่าผ่าน ด่านแบบนั้นให้ผลเขียวทั้งสี่แถวของตารางข้างบน รวมทั้งแถวที่รั่ว
สิ่งที่ด่านนั้นไม่ได้ตรวจคือ แถวที่ได้กลับไปเป็นของใคร ซึ่งเป็นคีย์คนละตัว
คือ hijackOwnRowsA และ hijackOwnRowsB ไม่ใช่ hijackOwnTotal
probe วัดการค้นชื่อ ฟังก์ชัน ทรงเดียว กฎของชื่อ ตาราง ไม่เหมือนกัน และคอร์สนี้ไม่ได้วัดมันสักทรง ข้ออ้างของบทนี้จึงไม่ครอบชื่อตาราง
ผลทั้งหมดวัดบน PostgreSQL 18.6 บน aarch64-unknown-linux-musl
ใน container ของคอร์สนี้เอง major อื่นเป็นคนละการวัด
spike.py รันชุดข้ออ้างทั้งชุดสามรอบต่อการเรียกหนึ่งครั้ง รอบที่รันเมื่อ 2026-09-06
ได้ 52 ข้ออ้างตรงทั้งหมด 98 ค่าที่วัดได้ และ 0 ค่าที่ขยับระหว่างรอบ
คำถามที่บทนี้ทิ้งไว้
หัวข้อที่มีชื่อว่า “คำถามที่บทนี้ทิ้งไว้”ค่าตั้งที่ policy อ่าน ถูกอ่านผิดตัวได้แล้ว คำถามถัดไปคือมันอยู่ได้นานแค่ไหน
🔁 บทที่ 5 กับบทนี้เข้าหาปัญหาเดียวกันคนละด้าน บทที่ 5 ถามว่าใครเป็นคนเรียกนิพจน์ บทนี้ถามว่านิพจน์นั้นอ่านค่ามาจากที่ไหน
ทุกทรงในบทนี้อยู่ใน session เดียว และตั้ง app.tenant ใหม่ทุกครั้งที่วัด
ส่วน 🔁 บทที่ 2
วัดไว้แล้วว่าค่านั้นมีอายุของมันเอง และการล้างค่าไม่ได้คืนสถานะที่ไม่เคยตั้ง
บทที่ 7 ย้ายทั้งหมดนี้ไปอยู่หลังแอปที่ใช้ connection pool แล้ววัดว่าคำขอที่สอง
บน connection เดิม ได้แถวของใคร ที่นั่นไม่มีใครสั่ง set search_path เลยสักบรรทัด
แต่ค่าที่ policy อ่าน ยังเป็นค่าที่คนอื่นตั้งไว้
-
PostgreSQL 18 —
search_path(เข้าถึง 2026-09-06) — ที่มาของกฎว่า schema ชั่วคราวถูกค้นสำหรับชื่อของ relation และชื่อของ data type แต่ไม่ถูกค้นสำหรับชื่อฟังก์ชันและชื่อ operator -
PostgreSQL 18 —
CREATE FUNCTION(เข้าถึง 2026-09-06) — การผูกค่าตั้งไว้กับตัวฟังก์ชัน และหัวข้อการเขียนฟังก์ชันอย่างปลอดภัย -
PostgreSQL 18 — Dependency Tracking (เข้าถึง 2026-09-06) — การบันทึกการอ้างถึง object ที่ปรากฏในนิพจน์ที่เก็บไว้
-
PostgreSQL 18 —
ALTER FUNCTION(เข้าถึง 2026-09-06) — ความหมายของset search_pathที่ผูกลงบนฟังก์ชันที่มีอยู่แล้ว -
ตัวเลขทุกตัวในบทนี้มาจากทรงที่ 13 ของ
scripts/clinic-rls/spike.pyซึ่งสร้างฐานข้อมูล กับ role ขึ้นใหม่ทุกรอบ แล้วรื้อทิ้งเมื่อจบ · ต้องสั่งscripts/clinic-rls/up.shก่อน ซึ่งยก container ของคอร์สนี้เองขึ้นที่ port 55438
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3app role ตั้ง `app.tenant` เป็น `clinic-a` แล้วสั่ง `set search_path = app_own, public` โดยที่ `app_own.guard()` คืน `clinic-b` ผู้อ่านนับแถวได้เท่าไร และเป็นแถวของใคร