VYPR
Moderate severityNVD Advisory· Published Mar 24, 2026· Updated Mar 26, 2026

sbt: Source dependency feature (via crafted VCS URL) leads to arbitrary code execution on Windows

CVE-2026-32948

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.

PackageAffected versionsPatched versions
org.scala-sbt:sbtMaven
>= 0.9.5, < 1.12.81.12.8

Affected products

2
  • sbt/sbtllm-create
    Range: >=0.9.5, <1.12.7
  • sbt/sbtv5
    Range: >= 0.9.5, < 1.12.7

Patches

2
3a474ab060df

Allowlist-based approach to VCS string sanitization

https://github.com/sbt/sbtAnatolii KmetiukMar 23, 2026via ghsa
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")
     
    
1ce945b6b79c

Harden Windows VCS URI fragments against command injection

https://github.com/sbt/sbtAnatolii KmetiukMar 23, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.