sbt: Source dependency feature (via crafted VCS URL) leads to arbitrary code execution on Windows
Description
sbt is a build tool for Scala, Java, and others. From version 0.9.5 to before version 1.12.7, on Windows, sbt uses Process("cmd", "/c", ...) to run VCS commands (git, hg, svn). The URI fragment (branch, tag, revision) is user-controlled via the build definition and passed to these commands without validation. Because cmd /c interprets &, |, and ; as command separators, a malicious fragment can execute arbitrary commands. This issue has been patched in version 1.12.7.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A command injection vulnerability in sbt on Windows allows arbitrary code execution via crafted VCS URI fragments.
Root
Cause
sbt on Windows uses Process("cmd", "/c", ...) to execute VCS commands (git, hg, svn) for source dependencies. The URI fragment (e.g., branch name) is user-controlled via the build definition and passed directly to the shell command without sanitization [1]. Because cmd /c interprets &, |, and ; as command separators, a malicious fragment can inject additional commands [4].
Exploitation
An attacker can craft a VCS URI with a fragment containing shell metacharacters. For example, a URI like https://github.com/sbt/io.git#main%26%26calc.exe will be passed to cmd /c, executing calc.exe after the VCS command [4]. The attacker must be able to control a dependency URI in the project's build definition, such as through a malicious build.sbt or a compromised repository.
Impact
Successful exploitation allows arbitrary command execution on the build machine with the privileges of the user running sbt [1]. This can lead to full compromise of the development environment, including data exfiltration, installation of malware, or lateral movement within corporate networks. All Windows users of sbt versions 0.9.5 through 1.12.6 are affected [1].
Mitigation
The issue is patched in sbt 1.12.7, but the advisory notes a bug that breaks source dependency functionality, recommending upgrade to 1.12.8 or later [4]. The fix introduces validation of URI fragments, rejecting metacharacters like &, |, ;, and others [2][3]. Users should update their sbt version promptly.
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.scala-sbt:sbtMaven | >= 0.9.5, < 1.12.8 | 1.12.8 |
Affected products
2- sbt/sbtv5Range: >= 0.9.5, < 1.12.7
Patches
23a474ab060dfAllowlist-based approach to VCS string sanitization
2 files changed · +32 −6
main/src/main/scala/sbt/internal/VcsUriFragment.scala+10 −6 modified@@ -14,15 +14,19 @@ private[sbt] object VcsUriFragment { def validate(fragment: String): Unit = { if (fragment == null) throw new IllegalArgumentException("VCS URI fragment must not be null") + if (fragment.isEmpty) + throw new IllegalArgumentException("VCS URI fragment must not be empty") fragment.foreach { c => - if (c == '&' || c == '|' || c == ';') + if (!isAllowed(c)) throw new IllegalArgumentException( - "Invalid character in VCS URI fragment (shell metacharacters are not allowed)" - ) - if (Character.isISOControl(c)) - throw new IllegalArgumentException( - "Invalid character in VCS URI fragment (control characters are not allowed)" + "Invalid character in VCS URI fragment (only ASCII letters, digits, and - _ . / + are allowed)" ) } } + + private def isAllowed(c: Char): Boolean = + (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '/' || c == '+' }
main/src/test/scala/sbt/internal/VcsUriFragmentTest.scala+22 −0 modified@@ -15,9 +15,14 @@ import hedgehog.runner.* object VcsUriFragmentTest extends Properties: override def tests: List[Test] = List( example("accepts typical branch and tag names", testAcceptsSafe), + example("accepts hex commit id fragment", testAcceptsHexSha), + example("rejects empty fragment", testRejectsEmpty), example("rejects ampersand", testRejectsAmpersand), example("rejects pipe", testRejectsPipe), example("rejects semicolon", testRejectsSemicolon), + example("rejects space", testRejectsSpace), + example("rejects percent", testRejectsPercent), + example("rejects greater-than", testRejectsGreaterThan), example("rejects newline", testRejectsNewline), example("rejects DEL", testRejectsDel), ) @@ -26,8 +31,16 @@ object VcsUriFragmentTest extends Properties: VcsUriFragment.validate("develop") VcsUriFragment.validate("v1.2.3") VcsUriFragment.validate("feature/foo-bar") + VcsUriFragment.validate("release/1.0.0+build") Result.success + def testAcceptsHexSha: Result = + VcsUriFragment.validate("abc123def4567890abcdef1234567890abcdef12") + Result.success + + def testRejectsEmpty: Result = + interceptIllegal("") + def testRejectsAmpersand: Result = interceptIllegal("a&b") @@ -37,6 +50,15 @@ object VcsUriFragmentTest extends Properties: def testRejectsSemicolon: Result = interceptIllegal("a;b") + def testRejectsSpace: Result = + interceptIllegal("a b") + + def testRejectsPercent: Result = + interceptIllegal("a%20b") + + def testRejectsGreaterThan: Result = + interceptIllegal("a>b") + def testRejectsNewline: Result = interceptIllegal("a\nb")
1ce945b6b79cHarden Windows VCS URI fragments against command injection
4 files changed · +185 −9
main/src/main/scala/sbt/internal/VcsUriFragment.scala+28 −0 added@@ -0,0 +1,28 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +package internal + +private[sbt] object VcsUriFragment { + + def validate(fragment: String): Unit = { + if (fragment == null) + throw new IllegalArgumentException("VCS URI fragment must not be null") + fragment.foreach { c => + if (c == '&' || c == '|' || c == ';') + throw new IllegalArgumentException( + "Invalid character in VCS URI fragment (shell metacharacters are not allowed)" + ) + if (Character.isISOControl(c)) + throw new IllegalArgumentException( + "Invalid character in VCS URI fragment (control characters are not allowed)" + ) + } + } +}
main/src/main/scala/sbt/Resolvers.scala+20 −9 modified@@ -19,9 +19,10 @@ import BuildLoader.ResolveInfo import RichURI.fromURI import java.util.Locale -import scala.sys.process.Process +import scala.sys.process.{ BasicIO, Process } import scala.util.control.NonFatal -import sbt.internal.util.Util +import sbt.util.Logger +import sbt.internal.VcsUriFragment object Resolvers { type Resolver = BuildLoader.Resolver @@ -55,6 +56,7 @@ object Resolvers { if (uri.hasFragment) { val revision = uri.getFragment + VcsUriFragment.validate(revision) Some { () => creates(localCopy) { run("svn", "checkout", "-q", "-r", revision, from, to) @@ -87,6 +89,7 @@ object Resolvers { if (uri.hasFragment) { val branch = uri.getFragment + VcsUriFragment.validate(branch) Some { () => creates(localCopy) { run("git", "clone", from, localCopy.getAbsolutePath) @@ -115,6 +118,7 @@ object Resolvers { if (uri.hasFragment) { val branch = uri.getFragment + VcsUriFragment.validate(branch) Some { () => creates(localCopy) { clone(from, to = localCopy) @@ -133,12 +137,19 @@ object Resolvers { def run(command: String*): Unit = run(None, command: _*) - def run(cwd: Option[File], command: String*): Unit = { - val result = Process( - if (Util.isNonCygwinWindows) "cmd" +: "/c" +: command - else command, - cwd - ) !; + def run(cwd: Option[File], command: String*): Unit = + run(None, None, command: _*) + + private def run(cwd: Option[File], log: Option[Logger], command: String*): Unit = { + val process = Process(command, cwd) + val result = (log match { + case Some(log) => + val io = BasicIO(false, log).withInput(_.close()) + process.run(io).exitValue() + case None => + process.run().exitValue() + }) + if (result != 0) sys.error("Nonzero exit code (" + result + "): " + command.mkString(" ")) } @@ -172,6 +183,6 @@ object Resolvers { private[this] def normalizeDirectoryName(name: String): String = dropExtensions(name).toLowerCase(Locale.ENGLISH).replaceAll("""\W+""", "-") - private[this] def dropExtensions(name: String): String = name.takeWhile(_ != '.') + private def dropExtensions(name: String): String = name.takeWhile(_ != '.') }
main/src/test/scala/sbt/internal/ResolversVcsSecurityTest.scala+85 −0 added@@ -0,0 +1,85 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +package internal + +import hedgehog.* +import hedgehog.runner.* + +import java.net.URI +import scala.jdk.CollectionConverters.* + +import _root_.sbt.Resolvers +import _root_.sbt.io.IO +import _root_.sbt.internal.BuildLoader.ResolveInfo + +object ResolversVcsSecurityTest extends Properties: + + override def tests: List[Test] = List( + example( + "git resolver rejects fragment containing & before running VCS", + testResolverRejects(Resolvers.git, vcsUri("git", "/repo.git", "main&evil")) + ), + example( + "git resolver rejects fragment containing |", + testResolverRejects(Resolvers.git, vcsUri("git", "/repo.git", "main|evil")) + ), + example( + "git resolver rejects fragment containing ;", + testResolverRejects(Resolvers.git, vcsUri("git", "/repo.git", "main;evil")) + ), + example( + "mercurial resolver rejects fragment containing & before running VCS", + testResolverRejects(Resolvers.mercurial, vcsUri("hg", "/repo", "main&evil")) + ), + example( + "subversion resolver rejects fragment containing & before running VCS", + testResolverRejects(Resolvers.subversion, vcsUri("svn", "/repo", "main&evil")) + ), + example( + "git resolver accepts safe branch fragment and returns Some", + testResolverAccepts(Resolvers.git, vcsUri("git", "/repo.git", "develop")) + ), + example( + "ProcessBuilder passes VCS ref as a single argv element (no shell parsing)", + testProcessBuilderKeepsMetacharactersInSingleArgument + ), + ) + + private def vcsUri(scheme: String, path: String, fragment: String): URI = + new URI(scheme, null, "example.com", -1, path, null, fragment) + + private def testResolverRejects(resolver: Resolvers.Resolver, uri: URI): Result = + val staging = IO.createTemporaryDirectory + try + val info = new ResolveInfo(uri, staging, null, null) + try + resolver(info) + Result.failure.log(s"expected IllegalArgumentException for $uri") + catch case _: IllegalArgumentException => Result.success + finally IO.delete(staging) + + private def testResolverAccepts(resolver: Resolvers.Resolver, uri: URI): Result = + val staging = IO.createTemporaryDirectory + try + val info = new ResolveInfo(uri, staging, null, null) + try + resolver(info) match + case Some(_) => Result.success + case None => Result.failure.log(s"expected Some for $uri") + catch + case e: IllegalArgumentException => + Result.failure.log(s"unexpected IllegalArgumentException for $uri: ${e.getMessage}") + finally IO.delete(staging) + + private def testProcessBuilderKeepsMetacharactersInSingleArgument: Result = + val argv = new ProcessBuilder("git", "fetch", "origin", "topic&injection").command().asScala.toList + Result.assert(argv == List("git", "fetch", "origin", "topic&injection")) + +end ResolversVcsSecurityTest
main/src/test/scala/sbt/internal/VcsUriFragmentTest.scala+52 −0 added@@ -0,0 +1,52 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +package internal + +import hedgehog.* +import hedgehog.runner.* + +object VcsUriFragmentTest extends Properties: + override def tests: List[Test] = List( + example("accepts typical branch and tag names", testAcceptsSafe), + example("rejects ampersand", testRejectsAmpersand), + example("rejects pipe", testRejectsPipe), + example("rejects semicolon", testRejectsSemicolon), + example("rejects newline", testRejectsNewline), + example("rejects DEL", testRejectsDel), + ) + + def testAcceptsSafe: Result = + VcsUriFragment.validate("develop") + VcsUriFragment.validate("v1.2.3") + VcsUriFragment.validate("feature/foo-bar") + Result.success + + def testRejectsAmpersand: Result = + interceptIllegal("a&b") + + def testRejectsPipe: Result = + interceptIllegal("a|b") + + def testRejectsSemicolon: Result = + interceptIllegal("a;b") + + def testRejectsNewline: Result = + interceptIllegal("a\nb") + + def testRejectsDel: Result = + interceptIllegal("a\u007fb") + + private def interceptIllegal(s: String): Result = + try + VcsUriFragment.validate(s) + Result.failure.log(s"expected failure for ${s.map(_.toInt).mkString(",")}") + catch case _: IllegalArgumentException => Result.success + +end VcsUriFragmentTest
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-x4ff-q6h8-v7gwghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-32948ghsaADVISORY
- github.com/sbt/sbt/commit/1ce945b6b79cbe3cef6c0fe9efbbd2904e0f479eghsax_refsource_MISCWEB
- github.com/sbt/sbt/commit/3a474ab060df4dbfa825a7e7bc97e00056519800ghsax_refsource_MISCWEB
- github.com/sbt/sbt/releases/tag/v1.12.7ghsax_refsource_MISCWEB
- github.com/sbt/sbt/security/advisories/GHSA-x4ff-q6h8-v7gwghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.