Summary
When a non-PIE x86_64 executable calls dlopen() on its own path, box64 loads the file a
second time and runs its initialisers again. On native Linux glibc simply returns NULL for that
call and nothing is loaded twice.
The consequences are easy to overlook in a small program but fatal in a real one: the second copy
cannot be placed at the executable's fixed load address, so it is relocated — yet its absolute
addresses still point into the first image. Its initialisers therefore run against the running
program's data. A Delphi application dies with SIGSEGV shortly after.
This is not an exotic pattern. Every application built with Embarcadero Delphi for Linux does
this during startup (the RTL calls dladdr() and then dlopen() on the module's own file), and
Delphi always produces non-PIE executables — so currently no Delphi/Linux application runs under
box64.
Environment
- box64
fbbb054 (v0.4.5), -DRPI3ARM64=1 -DCMAKE_BUILD_TYPE=RelWithDebInfo,
cross-compiled with aarch64-linux-gnu-gcc 13.3.0
- Raspberry Pi 3 Model B Rev 1.2, Raspberry Pi OS Trixie arm64, kernel 6.18.50+rpt-rpi-v8, 4K pages
- box64 banner:
Dynarec for ARM64, with extension: ASIMD CRC32,
Didn't detect 48bits of address space, considering it's 39bits
Reproducer
/* build on x86_64: gcc -no-pie -o selfopen2 selfopen2.c -ldl
The constructor makes the double load visible: it must run exactly once. */
#include <stdio.h>
#include <dlfcn.h>
#include <limits.h>
#include <unistd.h>
static int counter = 0;
__attribute__((constructor))
static void ctor(void)
{
counter++;
printf(" ctor runs: counter at %p is now %d\n", (void*)&counter, counter);
fflush(stdout);
}
int main(void)
{
char self[PATH_MAX];
ssize_t n = readlink("/proc/self/exe", self, sizeof(self) - 1);
if (n < 0) { perror("readlink"); return 1; }
self[n] = '\0';
printf("main: counter at %p is %d\n", (void*)&counter, counter);
printf("opening myself: %s\n", self);
fflush(stdout);
void *h = dlopen(self, RTLD_LAZY);
printf("dlopen -> %p\n", h);
printf("main: counter at %p is %d (expected: still 1)\n", (void*)&counter, counter);
fflush(stdout);
return 0;
}
-no-pie matters: the binary must have a fixed load address (here 0x400000).
native x86_64 Linux (Ubuntu 24.04, glibc) — reference
ctor runs: counter at 0x40404c is now 1
main: counter at 0x40404c is 1
opening myself: /tmp/selfopen2
dlopen -> (nil)
main: counter at 0x40404c is 1 (expected: still 1)
glibc refuses to dlopen an executable and returns NULL. The constructor runs once.
box64 on the Pi
ctor runs: counter at 0x40404c is now 1
main: counter at 0x40404c is 1
opening myself: /tmp/selfopen2
[BOX64] Warning: cannot create memory map (@0x400000 0x4050) for elf "/tmp/selfopen2" got 0x7fa53db000
ctor runs: counter at 0x40404c is now 2 <-- second load, initialiser runs again
dlopen -> 0x4
main: counter at 0x40404c is 2 (expected: still 1)
Note the address: the relocated second copy writes into the running program's counter.
What it looks like in a real application
The Delphi HTTP server I was porting starts, reaches its entry point, and then dies:
[BOX64] Warning: cannot create memory map (@0x10000000 0x4108c0) for elf "…/VeGA_Server" got 0x7fb877c000
[BOX64] Pre-allocated 0x4108c0 byte at 0x7fb877c000 for …/VeGA_Server
[BOX64] Delta of 0x7fa877c000 (vaddr=0x10000000) for Elf "…/VeGA_Server"
[BOX64] Adding "…/VeGA_Server" as #2 in elf collection
…
[BOX64] Warning, calling Signal 11 function handler SIG_DFL
The warning is misleading in a way that costs hours: it reads like an address conflict, so one
starts looking at ASLR, mmap_min_addr, or the load address. None of that is the cause — the kernel
hands out exactly these hints without complaint (checked with a small C program calling mmap()
with the same address, size and flags), and relinking the application to a different base
(--image-base=0x10000000) only moves the same failure, because the range is occupied by the first
(correct) load of the very same binary.
Where it comes from
In src/wrapped/wrappedlibdl.c, my_dlopen_internal() first walks the already-opened libraries
(IsSameLib(dl->dllibs[i].lib, rfilename)) and then calls GetLibInternal(rfilename). Both only
know libraries; the main executable is not among them, so lib == NULL, dlopened becomes 1, and
AddNeededLib() loads the file again.
dlopen(NULL, …) is handled correctly a few lines below — it looks for an existing entry with
is_self and recycles it. Only the "open myself by name" spelling misses that path.
Two possible fixes
(a) Match glibc and return NULL when the path resolves to the main executable. Most faithful
to native behaviour; applications that do this already cope with NULL, since that is what they get
on real Linux.
(b) Return the is_self handle — more forgiving, and what I used locally:
// dlopen() on the main binary itself: hand out the "self" handle instead of loading
// the file a second time. A non-PIE executable cannot be mapped twice - the second
// copy lands elsewhere and its initialisers run against the first image's data.
if(my_context->fullpath && !strcmp(rfilename, my_context->fullpath)) {
for (size_t i=MIN_NLIB; i<dl->lib_sz; ++i) {
if(dl->dllibs[i].full && dl->dllibs[i].is_self) {
++dl->dllibs[i].count;
return (void*)(i+1);
}
}
if(dl->lib_sz == dl->lib_cap) {
dl->lib_cap += 4;
dl->dllibs = (dllib_t*)box_realloc(dl->dllibs, sizeof(dllib_t)*dl->lib_cap);
memset(dl->dllibs+dl->lib_sz, 0, (dl->lib_cap-dl->lib_sz)*sizeof(dllib_t));
if(!dl->lib_sz)
dl->lib_sz = MIN_NLIB;
}
intptr_t idx_self = dl->lib_sz++;
dl->dllibs[idx_self].lib = NULL;
++dl->dllibs[idx_self].count;
dl->dllibs[idx_self].dlopened = 0;
dl->dllibs[idx_self].is_self = 1;
dl->dllibs[idx_self].full = 1;
return (void*)(idx_self+1);
}
With (b) the reproducer's constructor runs once, counter stays 1, and the Delphi server starts and
serves requests on the Pi (0.89 s to first listen, ~20 ms per request, well usable).
A proper fix should probably compare resolved paths rather than the raw string (symlinks, ./name,
/proc/self/exe) and may want the same treatment in elfloader32.c. I kept the change minimal on
purpose. Happy to send this as a pull request in whichever of the two shapes you prefer.
Side note, in case it helps someone searching
Two further things were needed before the Delphi application ran, both unrelated to the bug above:
its embedded resources are not reachable under box64 (resourcestrings come out empty, .dfm
resources are not found), and ICU must be shipped as x86_64 libraries of exactly the version the
target carries — box64's wrapped native ICU is missing symbols the Delphi RTL looks up, ending in
Ask to run at NULL, will segfault. BOX64_EMULATED_LIBS only helps when the emulated files are
actually present; otherwise library.c falls back to the native wrapper.
Summary
When a non-PIE x86_64 executable calls
dlopen()on its own path, box64 loads the file asecond time and runs its initialisers again. On native Linux glibc simply returns
NULLfor thatcall and nothing is loaded twice.
The consequences are easy to overlook in a small program but fatal in a real one: the second copy
cannot be placed at the executable's fixed load address, so it is relocated — yet its absolute
addresses still point into the first image. Its initialisers therefore run against the running
program's data. A Delphi application dies with SIGSEGV shortly after.
This is not an exotic pattern. Every application built with Embarcadero Delphi for Linux does
this during startup (the RTL calls
dladdr()and thendlopen()on the module's own file), andDelphi always produces non-PIE executables — so currently no Delphi/Linux application runs under
box64.
Environment
fbbb054(v0.4.5),-DRPI3ARM64=1 -DCMAKE_BUILD_TYPE=RelWithDebInfo,cross-compiled with
aarch64-linux-gnu-gcc 13.3.0Dynarec for ARM64, with extension: ASIMD CRC32,Didn't detect 48bits of address space, considering it's 39bitsReproducer
-no-piematters: the binary must have a fixed load address (here0x400000).native x86_64 Linux (Ubuntu 24.04, glibc) — reference
glibc refuses to dlopen an executable and returns
NULL. The constructor runs once.box64 on the Pi
Note the address: the relocated second copy writes into the running program's
counter.What it looks like in a real application
The Delphi HTTP server I was porting starts, reaches its entry point, and then dies:
The warning is misleading in a way that costs hours: it reads like an address conflict, so one
starts looking at ASLR,
mmap_min_addr, or the load address. None of that is the cause — the kernelhands out exactly these hints without complaint (checked with a small C program calling
mmap()with the same address, size and flags), and relinking the application to a different base
(
--image-base=0x10000000) only moves the same failure, because the range is occupied by the first(correct) load of the very same binary.
Where it comes from
In
src/wrapped/wrappedlibdl.c,my_dlopen_internal()first walks the already-opened libraries(
IsSameLib(dl->dllibs[i].lib, rfilename)) and then callsGetLibInternal(rfilename). Both onlyknow libraries; the main executable is not among them, so
lib == NULL,dlopenedbecomes 1, andAddNeededLib()loads the file again.dlopen(NULL, …)is handled correctly a few lines below — it looks for an existing entry withis_selfand recycles it. Only the "open myself by name" spelling misses that path.Two possible fixes
(a) Match glibc and return
NULLwhen the path resolves to the main executable. Most faithfulto native behaviour; applications that do this already cope with
NULL, since that is what they geton real Linux.
(b) Return the
is_selfhandle — more forgiving, and what I used locally:With (b) the reproducer's constructor runs once,
counterstays 1, and the Delphi server starts andserves requests on the Pi (0.89 s to first listen, ~20 ms per request, well usable).
A proper fix should probably compare resolved paths rather than the raw string (symlinks,
./name,/proc/self/exe) and may want the same treatment inelfloader32.c. I kept the change minimal onpurpose. Happy to send this as a pull request in whichever of the two shapes you prefer.
Side note, in case it helps someone searching
Two further things were needed before the Delphi application ran, both unrelated to the bug above:
its embedded resources are not reachable under box64 (
resourcestrings come out empty,.dfmresources are not found), and ICU must be shipped as x86_64 libraries of exactly the version the
target carries — box64's wrapped native ICU is missing symbols the Delphi RTL looks up, ending in
Ask to run at NULL, will segfault.BOX64_EMULATED_LIBSonly helps when the emulated files areactually present; otherwise
library.cfalls back to the native wrapper.