Skip to content

Link Ruby with -z now for full RELRO - #529

Open
flavorjones wants to merge 1 commit into
docker-library:masterfrom
flavorjones:link-with-bind-now
Open

Link Ruby with -z now for full RELRO#529
flavorjones wants to merge 1 commit into
docker-library:masterfrom
flavorjones:link-with-bind-now

Conversation

@flavorjones

@flavorjones flavorjones commented Aug 26, 2026

Copy link
Copy Markdown

Motivation

Dockerfile.template calls configure without setting LDFLAGS, so the base image's linker
defaults decide the RELRO posture of the build. Those defaults differ across the bases this
repository builds:

base executable shared object
debian:bookworm (gcc 12.2.0) no BIND_NOW no BIND_NOW
debian:trixie (gcc 14.2.0) no BIND_NOW no BIND_NOW
alpine3.23 / alpine3.24 (gcc 15.2.0) BIND_NOW BIND_NOW

Measured with gcc -o exe h.c and gcc -shared -fPIC -o lib.so h.c on each base, where h.c is
an empty main.

So the Alpine variants already get full RELRO and the Debian variants get neither object. Since
the build is --enable-shared, /usr/local/bin/ruby is a small stub and the interpreter lives in
libruby.so, which means the object holding essentially all of the code is left with lazy binding.

Without -z now the loader cannot map the GOT read-only, so it stays writable for the life of the
process. That leaves a table of function pointers, which the process dereferences on every external
call, writable by anyone who already has an arbitrary write.

Debian and Ubuntu already link their own libruby packages this way. libruby-3.3.so.3.3 on
debian:trixie and libruby-3.2.so.3.2 on ubuntu:24.04 both report FLAGS: BIND_NOW. This
brings the images in line with the distribution packages they sit beside.

Detail

Export LDFLAGS before configure in Dockerfile.template, then regenerate. Ruby's configure
appends its own flags rather than replacing what it inherits, and : ${DLDFLAGS="$LDFLAGS"} in
configure.ac seeds DLDFLAGS from the
same value, so one export reaches the interpreter, libruby.so, and any native extension built
later in a derived image.

Building ruby-4.0.6 on debian:trixie-slim with --build, --disable-install-doc, and
--enable-shared, before and after:

bin/ruby libruby.so.4.0.6
before GNU_RELRO, no BIND_NOW GNU_RELRO, no BIND_NOW
after GNU_RELRO, BIND_NOW GNU_RELRO, BIND_NOW

The resulting RbConfig values, which is what native gems inherit:

before  LDFLAGS   -L. -fstack-protector-strong -rdynamic -Wl,-export-dynamic -Wl,--no-as-needed
        DLDFLAGS  -Wl,--compress-debug-sections=zlib

after   LDFLAGS   -L. -Wl,-z,relro,-z,now -fstack-protector-strong -rdynamic -Wl,-export-dynamic -Wl,--no-as-needed
        DLDFLAGS  -Wl,-z,relro,-z,now -Wl,--compress-debug-sections=zlib

The Alpine variants are unaffected, since -z relro -z now is already the Alpine default. The
export is in the shared part of the template rather than behind an is_alpine guard so that all
variants read the same and no base's defaults can silently drop the build to partial RELRO.

-z relro is likewise redundant on Debian, where it is already the default. It is spelled out so
the pair reads as one intent.

The cost is that symbol resolution happens eagerly at startup rather than on first call. Measured
with hyperfine, shell disabled, 30 warmup and 300 timed runs, pinned to one CPU, six repetitions
with alternating order, 1800 samples per image:

workload control -z now paired difference
ruby --disable-gems empty script 4.99ms 5.03ms +0.07ms (+1.5%)
ruby empty script 34.36ms 34.45ms -0.14ms (-0.4%)
ruby + 6 C extensions 56.84ms 57.38ms +0.55ms (+1.0%)
full Rails boot (1.33s) 1326ms 1331ms not detectable

Medians. The extension-heavy case is the one that isolates the mechanism, and its penalty is about
one percent, at the edge of what these runs separate from noise: the paired difference is +0.55ms
with a standard deviation of 0.71ms, though the sign held in 5 of 6 repetitions. At Rails boot
scale it disappears into run-to-run drift. libruby.so carries 1415 PLT relocations, so
sub-millisecond is the expected size.

The ${LDFLAGS:+ $LDFLAGS} suffix keeps any externally supplied LDFLAGS and lets it win, so a
caller who needs the old behaviour can still get it.

Built a real application against the patched image: Fizzy, a
Rails 8 app, using its own production Dockerfile unmodified apart from the base image.
bundle install compiled 62 native extensions under BUNDLE_DEPLOYMENT=1 with no failures. Puma
and SolidQueue boot, GET /up returns 200, and a rails runner round trip through SQLite and
Nokogiri works.

Of those 62 extensions, 48 gained BIND_NOW and 14 did not. The 14 are precompiled platform gems
(ffi, nokogiri, and sqlite3 in their -x86_64-linux-gnu builds) whose .so files are built
on the gem author's machine and shipped inside the gem, so no image-side LDFLAGS reaches them.
Everything Bundler compiles from source does inherit it.

Additional information

This came out of checking whether a Rails application container was exploitable given the
libheif issues fixed in 1.23.2, one of
which (GHSA-2jg2-4ch7-h545)
has a confirmed remote code execution proof of concept. The generated Rails Dockerfile starts
FROM docker.io/library/ruby:$RUBY_VERSION-slim and installs libvips, so that container is built
from this repository.

Everything else in that image held up. The interpreter is PIE, every library is PIC, ASLR is
enforced, and the container runs unprivileged. Partial RELRO on ruby and libruby was the one
gap.

libheif is only the example. The same reasoning applies to any C library a Ruby process links,
which for a typical application server is a long list.

The same change for ruby/docker-images is
ruby/docker-images#184.

Debian's linker defaults leave both `ruby` and `libruby.so` with lazy
binding, so their GOTs stay writable for the life of the process and
remain available as a target once an attacker has an arbitrary write.

Export `LDFLAGS` before `configure`. Ruby's `configure` appends to
`LDFLAGS` and seeds `DLDFLAGS` from it, so the interpreter, `libruby.so`,
and native extensions built downstream all pick it up. Alpine already
links this way, so the change is a no-op there.

The image codecs reachable through `libvips` have had many memory safety
bugs. Making the GOT read-only does not fix those bugs, but it removes
one of the easier ways to escalate a memory write into code execution.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant