Link Ruby with -z now for full RELRO - #529
Conversation
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.
|
Oh nice, thanks for the detailed writeup! It looks like Debian applies this via https://salsa.debian.org/ruby-team/ruby/-/blob/8a55ab83e2c1783d80d0117fecbda533ac75f3e3/debian/rules#L63-67 -- it looks like they also still do Here's what $ diff -u <(dpkg-buildflags) <(DEB_BUILD_MAINT_OPTIONS='hardening=+bindnow' dpkg-buildflags)
--- /dev/fd/63 2026-08-26 11:13:21.968376081 -0700
+++ /dev/fd/62 2026-08-26 11:13:21.968376081 -0700
@@ -12,8 +12,8 @@
FCFLAGS_FOR_BUILD=-g -O2 -ffile-prefix-map=/home/tianon=. -fstack-protector-strong -fstack-clash-protection -fcf-protection
FFLAGS=-g -O2 -ffile-prefix-map=/home/tianon=. -fstack-protector-strong -fstack-clash-protection -fcf-protection
FFLAGS_FOR_BUILD=-g -O2 -ffile-prefix-map=/home/tianon=. -fstack-protector-strong -fstack-clash-protection -fcf-protection
-LDFLAGS=-Wl,-z,relro
-LDFLAGS_FOR_BUILD=-Wl,-z,relro
+LDFLAGS=-Wl,-z,relro -Wl,-z,now
+LDFLAGS_FOR_BUILD=-Wl,-z,relro -Wl,-z,now
OBJCFLAGS=-g -O2 -ffile-prefix-map=/home/tianon=. -fstack-protector-strong -fstack-clash-protection -Wformat -Werror=format-security -fcf-protection
OBJCFLAGS_FOR_BUILD=-g -O2 -ffile-prefix-map=/home/tianon=. -fstack-protector-strong -fstack-clash-protection -Wformat -Werror=format-security -fcf-protection
OBJCXXFLAGS=-g -O2 -ffile-prefix-map=/home/tianon=. -fstack-protector-strong -fstack-clash-protection -Wformat -Werror=format-security -fcf-protection
This isn't necessary nor will it work inside a |
Motivation
Dockerfile.templatecallsconfigurewithout settingLDFLAGS, so the base image's linkerdefaults decide the RELRO posture of the build. Those defaults differ across the bases this
repository builds:
debian:bookworm(gcc 12.2.0)BIND_NOWBIND_NOWdebian:trixie(gcc 14.2.0)BIND_NOWBIND_NOWalpine3.23/alpine3.24(gcc 15.2.0)BIND_NOWBIND_NOWMeasured with
gcc -o exe h.candgcc -shared -fPIC -o lib.so h.con each base, whereh.cisan 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/rubyis a small stub and the interpreter lives inlibruby.so, which means the object holding essentially all of the code is left with lazy binding.Without
-z nowthe loader cannot map the GOT read-only, so it stays writable for the life of theprocess. 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
librubypackages this way.libruby-3.3.so.3.3ondebian:trixieandlibruby-3.2.so.3.2onubuntu:24.04both reportFLAGS: BIND_NOW. Thisbrings the images in line with the distribution packages they sit beside.
Detail
Export
LDFLAGSbeforeconfigureinDockerfile.template, then regenerate. Ruby'sconfigureappends its own flags rather than replacing what it inherits, and
: ${DLDFLAGS="$LDFLAGS"}inconfigure.acseedsDLDFLAGSfrom thesame value, so one export reaches the interpreter,
libruby.so, and any native extension builtlater in a derived image.
Building
ruby-4.0.6ondebian:trixie-slimwith--build,--disable-install-doc, and--enable-shared, before and after:bin/rubylibruby.so.4.0.6GNU_RELRO, noBIND_NOWGNU_RELRO, noBIND_NOWGNU_RELRO,BIND_NOWGNU_RELRO,BIND_NOWThe resulting
RbConfigvalues, which is what native gems inherit:The Alpine variants are unaffected, since
-z relro -z nowis already the Alpine default. Theexport is in the shared part of the template rather than behind an
is_alpineguard so that allvariants read the same and no base's defaults can silently drop the build to partial RELRO.
-z relrois likewise redundant on Debian, where it is already the default. It is spelled out sothe 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 repetitionswith alternating order, 1800 samples per image:
-z nowruby --disable-gemsempty scriptrubyempty scriptruby+ 6 C extensionsMedians. 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.socarries 1415 PLT relocations, sosub-millisecond is the expected size.
The
${LDFLAGS:+ $LDFLAGS}suffix keeps any externally suppliedLDFLAGSand lets it win, so acaller 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
Dockerfileunmodified apart from the base image.bundle installcompiled 62 native extensions underBUNDLE_DEPLOYMENT=1with no failures. Pumaand SolidQueue boot,
GET /upreturns 200, and arails runnerround trip through SQLite andNokogiri works.
Of those 62 extensions, 48 gained
BIND_NOWand 14 did not. The 14 are precompiled platform gems(
ffi,nokogiri, andsqlite3in their-x86_64-linux-gnubuilds) whose.sofiles are builton the gem author's machine and shipped inside the gem, so no image-side
LDFLAGSreaches 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
DockerfilestartsFROM docker.io/library/ruby:$RUBY_VERSION-slimand installslibvips, so that container is builtfrom 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
rubyandlibrubywas the onegap.
libheifis 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-imagesisruby/docker-images#184.