GitHub Actions Runner vulnerable to Docker Command Escaping
Description
GitHub Actions Runner prior to patched versions allows environment variable injection into docker commands, enabling command modification via untrusted inputs in container jobs.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
GitHub Actions Runner prior to patched versions allows environment variable injection into docker commands, enabling command modification via untrusted inputs in container jobs.
Vulnerability
Overview
CVE-2022-39321 is an injection vulnerability in the GitHub Actions Runner that arises from improper encoding of environment variables when constructing Docker CLI commands. The runner directly invokes the Docker CLI to run job containers, service containers, or container actions. A flaw in the logic that encodes environment variables into these commands allows an attacker to break out of the intended variable and inject arbitrary arguments or commands into the Docker invocation [1][2].
Exploitation
Prerequisites
Exploitation requires a workflow that uses container actions, job containers, or service containers and passes untrusted user input as an environment variable. An attacker who can control such input can craft a value that escapes the environment variable syntax and modifies the subsequent Docker command. This does not require authentication beyond the ability to trigger a workflow with controlled inputs [2].
Impact
Successful exploitation could allow an attacker to alter the Docker command executed by the runner, potentially leading to arbitrary command execution within the container context. This could compromise the runner, exfiltrate secrets, or disrupt the workflow execution [2].
Mitigation
GitHub has patched the runner in versions 2.296.2, 2.293.1, 2.289.4, 2.285.2, and 2.283.4. Runners on GitHub.com are automatically updated; GHES and GHAE customers should apply the provided hotfixes to ensure their runners upgrade. As a workaround, users can remove container actions, job containers, or service containers from their workflows until they can upgrade [2][3].
AI Insight generated on May 21, 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 |
|---|---|---|
actions/runnerGitHub Actions | >= 2.294.0, < 2.296.2 | 2.296.2 |
actions/runnerGitHub Actions | >= 2.290.0, < 2.293.1 | 2.293.1 |
actions/runnerGitHub Actions | >= 2.286.0, < 2.289.4 | 2.289.4 |
actions/runnerGitHub Actions | >= 2.284.0, < 2.285.2 | 2.285.2 |
actions/runnerGitHub Actions | < 2.283.4 | 2.283.4 |
Affected products
2- actions/runnerv5Range: < 2.283.4
Patches
72 files changed · +2 −2
releaseVersion+1 −1 modified@@ -1 +1 @@ -2.283.3 +2.283.4
releaseVersion+1 −1 modified@@ -1 +1 @@ -2.283.3 +2.283.4
2 files changed · +2 −2
releaseVersion+1 −1 modified@@ -1 +1 @@ -2.285.1 +2.285.2
releaseVersion+1 −1 modified@@ -1 +1 @@ -2.285.1 +2.285.2
2 files changed · +2 −2
releaseVersion+1 −1 modified@@ -1 +1 @@ -2.289.3 +2.289.4
releaseVersion+1 −1 modified@@ -1 +1 @@ -2.289.3 +2.289.4
2 files changed · +2 −2
releaseVersion+1 −1 modified@@ -1 +1 @@ -2.293.0 +2.293.1
releaseVersion+1 −1 modified@@ -1 +1 @@ -2.293.0 +2.293.1
2 files changed · +2 −2
releaseVersion+1 −1 modified@@ -1 +1 @@ -2.296.1 +2.296.2
releaseVersion+1 −1 modified@@ -1 +1 @@ -2.296.1 +2.296.2
ed191b78ae33Port hotfix to main branch (#2108)
10 files changed · +128 −42
releaseNote.md+1 −1 modified@@ -1,5 +1,5 @@ ## Bugs -- Fixed an issue where job and service container envs were corrupted (#2091) +- Fixed an issue where self hosted environments had their docker env's overwritten (#2107) ## Misc ## Windows x64
releaseNote.md+1 −1 modified@@ -1,5 +1,5 @@ ## Bugs -- Fixed an issue where job and service container envs were corrupted (#2091) +- Fixed an issue where self hosted environments had their docker env's overwritten (#2107) ## Misc ## Windows x64
src/runnerversion+1 −1 modified@@ -1 +1 @@ -2.296.1 +2.296.2
src/runnerversion+1 −1 modified@@ -1 +1 @@ -2.296.1 +2.296.2
src/Runner.Worker/Container/DockerCommandManager.cs+3 −10 modified@@ -107,7 +107,6 @@ public async Task<int> DockerBuild(IExecutionContext context, string workingDire public async Task<string> DockerCreate(IExecutionContext context, ContainerInfo container) { IList<string> dockerOptions = new List<string>(); - IDictionary<string, string> environment = new Dictionary<string, string>(); // OPTIONS dockerOptions.Add($"--name {container.ContainerDisplayName}"); dockerOptions.Add($"--label {DockerInstanceLabel}"); @@ -136,8 +135,7 @@ public async Task<string> DockerCreate(IExecutionContext context, ContainerInfo } else { - environment.Add(env.Key, env.Value); - dockerOptions.Add(DockerUtil.CreateEscapedOption("-e", env.Key)); + dockerOptions.Add(DockerUtil.CreateEscapedOption("-e", env.Key, env.Value)); } } @@ -185,7 +183,7 @@ public async Task<string> DockerCreate(IExecutionContext context, ContainerInfo dockerOptions.Add($"{container.ContainerEntryPointArgs}"); var optionsString = string.Join(" ", dockerOptions); - List<string> outputStrings = await ExecuteDockerCommandAsync(context, "create", optionsString, environment); + List<string> outputStrings = await ExecuteDockerCommandAsync(context, "create", optionsString); return outputStrings.FirstOrDefault(); } @@ -445,11 +443,6 @@ public Task<int> DockerLogin(IExecutionContext context, string configFileDirecto } private async Task<List<string>> ExecuteDockerCommandAsync(IExecutionContext context, string command, string options) - { - return await ExecuteDockerCommandAsync(context, command, options, null); - } - - private async Task<List<string>> ExecuteDockerCommandAsync(IExecutionContext context, string command, string options, IDictionary<string, string> environment) { string arg = $"{command} {options}".Trim(); context.Command($"{DockerPath} {arg}"); @@ -477,7 +470,7 @@ await processInvoker.ExecuteAsync( workingDirectory: context.GetGitHubContext("workspace"), fileName: DockerPath, arguments: arg, - environment: environment, + environment: null, requireExitCodeZero: true, outputEncoding: null, cancellationToken: CancellationToken.None);
src/Runner.Worker/Container/DockerCommandManager.cs+3 −10 modified@@ -107,7 +107,6 @@ public async Task<int> DockerBuild(IExecutionContext context, string workingDire public async Task<string> DockerCreate(IExecutionContext context, ContainerInfo container) { IList<string> dockerOptions = new List<string>(); - IDictionary<string, string> environment = new Dictionary<string, string>(); // OPTIONS dockerOptions.Add($"--name {container.ContainerDisplayName}"); dockerOptions.Add($"--label {DockerInstanceLabel}"); @@ -136,8 +135,7 @@ public async Task<string> DockerCreate(IExecutionContext context, ContainerInfo } else { - environment.Add(env.Key, env.Value); - dockerOptions.Add(DockerUtil.CreateEscapedOption("-e", env.Key)); + dockerOptions.Add(DockerUtil.CreateEscapedOption("-e", env.Key, env.Value)); } } @@ -185,7 +183,7 @@ public async Task<string> DockerCreate(IExecutionContext context, ContainerInfo dockerOptions.Add($"{container.ContainerEntryPointArgs}"); var optionsString = string.Join(" ", dockerOptions); - List<string> outputStrings = await ExecuteDockerCommandAsync(context, "create", optionsString, environment); + List<string> outputStrings = await ExecuteDockerCommandAsync(context, "create", optionsString); return outputStrings.FirstOrDefault(); } @@ -445,11 +443,6 @@ public Task<int> DockerLogin(IExecutionContext context, string configFileDirecto } private async Task<List<string>> ExecuteDockerCommandAsync(IExecutionContext context, string command, string options) - { - return await ExecuteDockerCommandAsync(context, command, options, null); - } - - private async Task<List<string>> ExecuteDockerCommandAsync(IExecutionContext context, string command, string options, IDictionary<string, string> environment) { string arg = $"{command} {options}".Trim(); context.Command($"{DockerPath} {arg}"); @@ -477,7 +470,7 @@ await processInvoker.ExecuteAsync( workingDirectory: context.GetGitHubContext("workspace"), fileName: DockerPath, arguments: arg, - environment: environment, + environment: null, requireExitCodeZero: true, outputEncoding: null, cancellationToken: CancellationToken.None);
src/Runner.Worker/Container/DockerUtil.cs+30 −2 modified@@ -6,6 +6,9 @@ namespace GitHub.Runner.Worker.Container { public class DockerUtil { + private static readonly Regex QuoteEscape = new Regex(@"(\\*)" + "\"", RegexOptions.Compiled); + private static readonly Regex EndOfStringEscape = new Regex(@"(\\+)$", RegexOptions.Compiled); + public static List<PortMapping> ParseDockerPort(IList<string> portMappingLines) { const string targetPort = "targetPort"; @@ -68,12 +71,37 @@ public static string CreateEscapedOption(string flag, string key) { return ""; } - return $"{flag} \"{EscapeString(key)}\""; + return $"{flag} {EscapeString(key)}"; + } + + public static string CreateEscapedOption(string flag, string key, string value) + { + if (String.IsNullOrEmpty(key)) + { + return ""; + } + var escapedString = EscapeString($"{key}={value}"); + return $"{flag} {escapedString}"; } private static string EscapeString(string value) { - return value.Replace("\\", "\\\\").Replace("\"", "\\\""); + if (String.IsNullOrEmpty(value)) + { + return ""; + } + // Dotnet escaping rules are weird here, we can only escape \ if it precedes a " + // If a double quotation mark follows two or an even number of backslashes, each proceeding backslash pair is replaced with one backslash and the double quotation mark is removed. + // If a double quotation mark follows an odd number of backslashes, including just one, each preceding pair is replaced with one backslash and the remaining backslash is removed; however, in this case the double quotation mark is not removed. + // https://docs.microsoft.com/en-us/dotnet/api/system.environment.getcommandlineargs?redirectedfrom=MSDN&view=net-6.0#remarks + + // First, find any \ followed by a " and double the number of \ + 1. + value = QuoteEscape.Replace(value, @"$1$1\" + "\""); + // Next, what if it ends in `\`, it would escape the end quote. So, we need to detect that at the end of the string and perform the same escape + // Luckily, we can just use the $ character with detects the end of string in regex + value = EndOfStringEscape.Replace(value, @"$1$1"); + // Finally, wrap it in quotes + return $"\"{value}\""; } } }
src/Runner.Worker/Container/DockerUtil.cs+30 −2 modified@@ -6,6 +6,9 @@ namespace GitHub.Runner.Worker.Container { public class DockerUtil { + private static readonly Regex QuoteEscape = new Regex(@"(\\*)" + "\"", RegexOptions.Compiled); + private static readonly Regex EndOfStringEscape = new Regex(@"(\\+)$", RegexOptions.Compiled); + public static List<PortMapping> ParseDockerPort(IList<string> portMappingLines) { const string targetPort = "targetPort"; @@ -68,12 +71,37 @@ public static string CreateEscapedOption(string flag, string key) { return ""; } - return $"{flag} \"{EscapeString(key)}\""; + return $"{flag} {EscapeString(key)}"; + } + + public static string CreateEscapedOption(string flag, string key, string value) + { + if (String.IsNullOrEmpty(key)) + { + return ""; + } + var escapedString = EscapeString($"{key}={value}"); + return $"{flag} {escapedString}"; } private static string EscapeString(string value) { - return value.Replace("\\", "\\\\").Replace("\"", "\\\""); + if (String.IsNullOrEmpty(value)) + { + return ""; + } + // Dotnet escaping rules are weird here, we can only escape \ if it precedes a " + // If a double quotation mark follows two or an even number of backslashes, each proceeding backslash pair is replaced with one backslash and the double quotation mark is removed. + // If a double quotation mark follows an odd number of backslashes, including just one, each preceding pair is replaced with one backslash and the remaining backslash is removed; however, in this case the double quotation mark is not removed. + // https://docs.microsoft.com/en-us/dotnet/api/system.environment.getcommandlineargs?redirectedfrom=MSDN&view=net-6.0#remarks + + // First, find any \ followed by a " and double the number of \ + 1. + value = QuoteEscape.Replace(value, @"$1$1\" + "\""); + // Next, what if it ends in `\`, it would escape the end quote. So, we need to detect that at the end of the string and perform the same escape + // Luckily, we can just use the $ character with detects the end of string in regex + value = EndOfStringEscape.Replace(value, @"$1$1"); + // Finally, wrap it in quotes + return $"\"{value}\""; } } }
src/Test/L0/Container/DockerUtilL0.cs+29 −7 modified@@ -149,13 +149,12 @@ public void ParseRegistryHostnameFromImageName(string input, string expected) [Trait("Level", "L0")] [Trait("Category", "Worker")] [InlineData("", "")] - [InlineData("HOME alpine:3.8 sh -c id #", "HOME alpine:3.8 sh -c id #")] - [InlineData("HOME \"alpine:3.8 sh -c id #", "HOME \\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \\\"alpine:3.8 sh -c id #", "HOME \\\\\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \\\\\"alpine:3.8 sh -c id #", "HOME \\\\\\\\\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \"\"alpine:3.8 sh -c id #", "HOME \\\"\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \\\"\"alpine:3.8 sh -c id #", "HOME \\\\\\\"\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \"\\\"alpine:3.8 sh -c id #", "HOME \\\"\\\\\\\"alpine:3.8 sh -c id #")] + [InlineData("foo", "foo")] + [InlineData("foo \\ bar", "foo \\ bar")] + [InlineData("foo \\", "foo \\\\")] + [InlineData("foo \\\\", "foo \\\\\\\\")] + [InlineData("foo \\\" bar", "foo \\\\\\\" bar")] + [InlineData("foo \\\\\" bar", "foo \\\\\\\\\\\" bar")] public void CreateEscapedOption_keyOnly(string input, string escaped) { var flag = "--example"; @@ -171,5 +170,28 @@ public void CreateEscapedOption_keyOnly(string input, string escaped) } Assert.Equal(expected, actual); } + + [Theory] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + [InlineData("foo", "bar", "foo=bar")] + [InlineData("foo\\", "bar", "foo\\=bar")] + [InlineData("foo\\", "bar\\", "foo\\=bar\\\\")] + [InlineData("foo \\","bar \\", "foo \\=bar \\\\")] + public void CreateEscapedOption_keyValue(string keyInput, string valueInput, string escapedString) + { + var flag = "--example"; + var actual = DockerUtil.CreateEscapedOption(flag, keyInput, valueInput); + string expected; + if (String.IsNullOrEmpty(keyInput)) + { + expected = ""; + } + else + { + expected = $"{flag} \"{escapedString}\""; + } + Assert.Equal(expected, actual); + } } }
src/Test/L0/Container/DockerUtilL0.cs+29 −7 modified@@ -149,13 +149,12 @@ public void ParseRegistryHostnameFromImageName(string input, string expected) [Trait("Level", "L0")] [Trait("Category", "Worker")] [InlineData("", "")] - [InlineData("HOME alpine:3.8 sh -c id #", "HOME alpine:3.8 sh -c id #")] - [InlineData("HOME \"alpine:3.8 sh -c id #", "HOME \\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \\\"alpine:3.8 sh -c id #", "HOME \\\\\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \\\\\"alpine:3.8 sh -c id #", "HOME \\\\\\\\\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \"\"alpine:3.8 sh -c id #", "HOME \\\"\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \\\"\"alpine:3.8 sh -c id #", "HOME \\\\\\\"\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \"\\\"alpine:3.8 sh -c id #", "HOME \\\"\\\\\\\"alpine:3.8 sh -c id #")] + [InlineData("foo", "foo")] + [InlineData("foo \\ bar", "foo \\ bar")] + [InlineData("foo \\", "foo \\\\")] + [InlineData("foo \\\\", "foo \\\\\\\\")] + [InlineData("foo \\\" bar", "foo \\\\\\\" bar")] + [InlineData("foo \\\\\" bar", "foo \\\\\\\\\\\" bar")] public void CreateEscapedOption_keyOnly(string input, string escaped) { var flag = "--example"; @@ -171,5 +170,28 @@ public void CreateEscapedOption_keyOnly(string input, string escaped) } Assert.Equal(expected, actual); } + + [Theory] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + [InlineData("foo", "bar", "foo=bar")] + [InlineData("foo\\", "bar", "foo\\=bar")] + [InlineData("foo\\", "bar\\", "foo\\=bar\\\\")] + [InlineData("foo \\","bar \\", "foo \\=bar \\\\")] + public void CreateEscapedOption_keyValue(string keyInput, string valueInput, string escapedString) + { + var flag = "--example"; + var actual = DockerUtil.CreateEscapedOption(flag, keyInput, valueInput); + string expected; + if (String.IsNullOrEmpty(keyInput)) + { + expected = ""; + } + else + { + expected = $"{flag} \"{escapedString}\""; + } + Assert.Equal(expected, actual); + } } }
96f4f5e76e5b2.296.2 Release notes (#2107)
10 files changed · +128 −42
releaseNote.md+1 −1 modified@@ -1,5 +1,5 @@ ## Bugs -- Fixed an issue where job and service container envs were corrupted (#2091) +- Fixed an issue where self hosted environments had their docker env's overwritten (#2107) ## Misc ## Windows x64
releaseNote.md+1 −1 modified@@ -1,5 +1,5 @@ ## Bugs -- Fixed an issue where job and service container envs were corrupted (#2091) +- Fixed an issue where self hosted environments had their docker env's overwritten (#2107) ## Misc ## Windows x64
src/runnerversion+1 −1 modified@@ -1 +1 @@ -2.296.1 +2.296.2
src/runnerversion+1 −1 modified@@ -1 +1 @@ -2.296.1 +2.296.2
src/Runner.Worker/Container/DockerCommandManager.cs+3 −10 modified@@ -107,7 +107,6 @@ public async Task<int> DockerBuild(IExecutionContext context, string workingDire public async Task<string> DockerCreate(IExecutionContext context, ContainerInfo container) { IList<string> dockerOptions = new List<string>(); - IDictionary<string, string> environment = new Dictionary<string, string>(); // OPTIONS dockerOptions.Add($"--name {container.ContainerDisplayName}"); dockerOptions.Add($"--label {DockerInstanceLabel}"); @@ -136,8 +135,7 @@ public async Task<string> DockerCreate(IExecutionContext context, ContainerInfo } else { - environment.Add(env.Key, env.Value); - dockerOptions.Add(DockerUtil.CreateEscapedOption("-e", env.Key)); + dockerOptions.Add(DockerUtil.CreateEscapedOption("-e", env.Key, env.Value)); } } @@ -185,7 +183,7 @@ public async Task<string> DockerCreate(IExecutionContext context, ContainerInfo dockerOptions.Add($"{container.ContainerEntryPointArgs}"); var optionsString = string.Join(" ", dockerOptions); - List<string> outputStrings = await ExecuteDockerCommandAsync(context, "create", optionsString, environment); + List<string> outputStrings = await ExecuteDockerCommandAsync(context, "create", optionsString); return outputStrings.FirstOrDefault(); } @@ -445,11 +443,6 @@ public Task<int> DockerLogin(IExecutionContext context, string configFileDirecto } private async Task<List<string>> ExecuteDockerCommandAsync(IExecutionContext context, string command, string options) - { - return await ExecuteDockerCommandAsync(context, command, options, null); - } - - private async Task<List<string>> ExecuteDockerCommandAsync(IExecutionContext context, string command, string options, IDictionary<string, string> environment) { string arg = $"{command} {options}".Trim(); context.Command($"{DockerPath} {arg}"); @@ -477,7 +470,7 @@ await processInvoker.ExecuteAsync( workingDirectory: context.GetGitHubContext("workspace"), fileName: DockerPath, arguments: arg, - environment: environment, + environment: null, requireExitCodeZero: true, outputEncoding: null, cancellationToken: CancellationToken.None);
src/Runner.Worker/Container/DockerCommandManager.cs+3 −10 modified@@ -107,7 +107,6 @@ public async Task<int> DockerBuild(IExecutionContext context, string workingDire public async Task<string> DockerCreate(IExecutionContext context, ContainerInfo container) { IList<string> dockerOptions = new List<string>(); - IDictionary<string, string> environment = new Dictionary<string, string>(); // OPTIONS dockerOptions.Add($"--name {container.ContainerDisplayName}"); dockerOptions.Add($"--label {DockerInstanceLabel}"); @@ -136,8 +135,7 @@ public async Task<string> DockerCreate(IExecutionContext context, ContainerInfo } else { - environment.Add(env.Key, env.Value); - dockerOptions.Add(DockerUtil.CreateEscapedOption("-e", env.Key)); + dockerOptions.Add(DockerUtil.CreateEscapedOption("-e", env.Key, env.Value)); } } @@ -185,7 +183,7 @@ public async Task<string> DockerCreate(IExecutionContext context, ContainerInfo dockerOptions.Add($"{container.ContainerEntryPointArgs}"); var optionsString = string.Join(" ", dockerOptions); - List<string> outputStrings = await ExecuteDockerCommandAsync(context, "create", optionsString, environment); + List<string> outputStrings = await ExecuteDockerCommandAsync(context, "create", optionsString); return outputStrings.FirstOrDefault(); } @@ -445,11 +443,6 @@ public Task<int> DockerLogin(IExecutionContext context, string configFileDirecto } private async Task<List<string>> ExecuteDockerCommandAsync(IExecutionContext context, string command, string options) - { - return await ExecuteDockerCommandAsync(context, command, options, null); - } - - private async Task<List<string>> ExecuteDockerCommandAsync(IExecutionContext context, string command, string options, IDictionary<string, string> environment) { string arg = $"{command} {options}".Trim(); context.Command($"{DockerPath} {arg}"); @@ -477,7 +470,7 @@ await processInvoker.ExecuteAsync( workingDirectory: context.GetGitHubContext("workspace"), fileName: DockerPath, arguments: arg, - environment: environment, + environment: null, requireExitCodeZero: true, outputEncoding: null, cancellationToken: CancellationToken.None);
src/Runner.Worker/Container/DockerUtil.cs+30 −2 modified@@ -6,6 +6,9 @@ namespace GitHub.Runner.Worker.Container { public class DockerUtil { + private static readonly Regex QuoteEscape = new Regex(@"(\\*)" + "\"", RegexOptions.Compiled); + private static readonly Regex EndOfStringEscape = new Regex(@"(\\+)$", RegexOptions.Compiled); + public static List<PortMapping> ParseDockerPort(IList<string> portMappingLines) { const string targetPort = "targetPort"; @@ -68,12 +71,37 @@ public static string CreateEscapedOption(string flag, string key) { return ""; } - return $"{flag} \"{EscapeString(key)}\""; + return $"{flag} {EscapeString(key)}"; + } + + public static string CreateEscapedOption(string flag, string key, string value) + { + if (String.IsNullOrEmpty(key)) + { + return ""; + } + var escapedString = EscapeString($"{key}={value}"); + return $"{flag} {escapedString}"; } private static string EscapeString(string value) { - return value.Replace("\\", "\\\\").Replace("\"", "\\\""); + if (String.IsNullOrEmpty(value)) + { + return ""; + } + // Dotnet escaping rules are weird here, we can only escape \ if it precedes a " + // If a double quotation mark follows two or an even number of backslashes, each proceeding backslash pair is replaced with one backslash and the double quotation mark is removed. + // If a double quotation mark follows an odd number of backslashes, including just one, each preceding pair is replaced with one backslash and the remaining backslash is removed; however, in this case the double quotation mark is not removed. + // https://docs.microsoft.com/en-us/dotnet/api/system.environment.getcommandlineargs?redirectedfrom=MSDN&view=net-6.0#remarks + + // First, find any \ followed by a " and double the number of \ + 1. + value = QuoteEscape.Replace(value, @"$1$1\" + "\""); + // Next, what if it ends in `\`, it would escape the end quote. So, we need to detect that at the end of the string and perform the same escape + // Luckily, we can just use the $ character with detects the end of string in regex + value = EndOfStringEscape.Replace(value, @"$1$1"); + // Finally, wrap it in quotes + return $"\"{value}\""; } } }
src/Runner.Worker/Container/DockerUtil.cs+30 −2 modified@@ -6,6 +6,9 @@ namespace GitHub.Runner.Worker.Container { public class DockerUtil { + private static readonly Regex QuoteEscape = new Regex(@"(\\*)" + "\"", RegexOptions.Compiled); + private static readonly Regex EndOfStringEscape = new Regex(@"(\\+)$", RegexOptions.Compiled); + public static List<PortMapping> ParseDockerPort(IList<string> portMappingLines) { const string targetPort = "targetPort"; @@ -68,12 +71,37 @@ public static string CreateEscapedOption(string flag, string key) { return ""; } - return $"{flag} \"{EscapeString(key)}\""; + return $"{flag} {EscapeString(key)}"; + } + + public static string CreateEscapedOption(string flag, string key, string value) + { + if (String.IsNullOrEmpty(key)) + { + return ""; + } + var escapedString = EscapeString($"{key}={value}"); + return $"{flag} {escapedString}"; } private static string EscapeString(string value) { - return value.Replace("\\", "\\\\").Replace("\"", "\\\""); + if (String.IsNullOrEmpty(value)) + { + return ""; + } + // Dotnet escaping rules are weird here, we can only escape \ if it precedes a " + // If a double quotation mark follows two or an even number of backslashes, each proceeding backslash pair is replaced with one backslash and the double quotation mark is removed. + // If a double quotation mark follows an odd number of backslashes, including just one, each preceding pair is replaced with one backslash and the remaining backslash is removed; however, in this case the double quotation mark is not removed. + // https://docs.microsoft.com/en-us/dotnet/api/system.environment.getcommandlineargs?redirectedfrom=MSDN&view=net-6.0#remarks + + // First, find any \ followed by a " and double the number of \ + 1. + value = QuoteEscape.Replace(value, @"$1$1\" + "\""); + // Next, what if it ends in `\`, it would escape the end quote. So, we need to detect that at the end of the string and perform the same escape + // Luckily, we can just use the $ character with detects the end of string in regex + value = EndOfStringEscape.Replace(value, @"$1$1"); + // Finally, wrap it in quotes + return $"\"{value}\""; } } }
src/Test/L0/Container/DockerUtilL0.cs+29 −7 modified@@ -149,13 +149,12 @@ public void ParseRegistryHostnameFromImageName(string input, string expected) [Trait("Level", "L0")] [Trait("Category", "Worker")] [InlineData("", "")] - [InlineData("HOME alpine:3.8 sh -c id #", "HOME alpine:3.8 sh -c id #")] - [InlineData("HOME \"alpine:3.8 sh -c id #", "HOME \\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \\\"alpine:3.8 sh -c id #", "HOME \\\\\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \\\\\"alpine:3.8 sh -c id #", "HOME \\\\\\\\\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \"\"alpine:3.8 sh -c id #", "HOME \\\"\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \\\"\"alpine:3.8 sh -c id #", "HOME \\\\\\\"\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \"\\\"alpine:3.8 sh -c id #", "HOME \\\"\\\\\\\"alpine:3.8 sh -c id #")] + [InlineData("foo", "foo")] + [InlineData("foo \\ bar", "foo \\ bar")] + [InlineData("foo \\", "foo \\\\")] + [InlineData("foo \\\\", "foo \\\\\\\\")] + [InlineData("foo \\\" bar", "foo \\\\\\\" bar")] + [InlineData("foo \\\\\" bar", "foo \\\\\\\\\\\" bar")] public void CreateEscapedOption_keyOnly(string input, string escaped) { var flag = "--example"; @@ -171,5 +170,28 @@ public void CreateEscapedOption_keyOnly(string input, string escaped) } Assert.Equal(expected, actual); } + + [Theory] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + [InlineData("foo", "bar", "foo=bar")] + [InlineData("foo\\", "bar", "foo\\=bar")] + [InlineData("foo\\", "bar\\", "foo\\=bar\\\\")] + [InlineData("foo \\","bar \\", "foo \\=bar \\\\")] + public void CreateEscapedOption_keyValue(string keyInput, string valueInput, string escapedString) + { + var flag = "--example"; + var actual = DockerUtil.CreateEscapedOption(flag, keyInput, valueInput); + string expected; + if (String.IsNullOrEmpty(keyInput)) + { + expected = ""; + } + else + { + expected = $"{flag} \"{escapedString}\""; + } + Assert.Equal(expected, actual); + } } }
src/Test/L0/Container/DockerUtilL0.cs+29 −7 modified@@ -149,13 +149,12 @@ public void ParseRegistryHostnameFromImageName(string input, string expected) [Trait("Level", "L0")] [Trait("Category", "Worker")] [InlineData("", "")] - [InlineData("HOME alpine:3.8 sh -c id #", "HOME alpine:3.8 sh -c id #")] - [InlineData("HOME \"alpine:3.8 sh -c id #", "HOME \\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \\\"alpine:3.8 sh -c id #", "HOME \\\\\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \\\\\"alpine:3.8 sh -c id #", "HOME \\\\\\\\\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \"\"alpine:3.8 sh -c id #", "HOME \\\"\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \\\"\"alpine:3.8 sh -c id #", "HOME \\\\\\\"\\\"alpine:3.8 sh -c id #")] - [InlineData("HOME \"\\\"alpine:3.8 sh -c id #", "HOME \\\"\\\\\\\"alpine:3.8 sh -c id #")] + [InlineData("foo", "foo")] + [InlineData("foo \\ bar", "foo \\ bar")] + [InlineData("foo \\", "foo \\\\")] + [InlineData("foo \\\\", "foo \\\\\\\\")] + [InlineData("foo \\\" bar", "foo \\\\\\\" bar")] + [InlineData("foo \\\\\" bar", "foo \\\\\\\\\\\" bar")] public void CreateEscapedOption_keyOnly(string input, string escaped) { var flag = "--example"; @@ -171,5 +170,28 @@ public void CreateEscapedOption_keyOnly(string input, string escaped) } Assert.Equal(expected, actual); } + + [Theory] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + [InlineData("foo", "bar", "foo=bar")] + [InlineData("foo\\", "bar", "foo\\=bar")] + [InlineData("foo\\", "bar\\", "foo\\=bar\\\\")] + [InlineData("foo \\","bar \\", "foo \\=bar \\\\")] + public void CreateEscapedOption_keyValue(string keyInput, string valueInput, string escapedString) + { + var flag = "--example"; + var actual = DockerUtil.CreateEscapedOption(flag, keyInput, valueInput); + string expected; + if (String.IsNullOrEmpty(keyInput)) + { + expected = ""; + } + else + { + expected = $"{flag} \"{escapedString}\""; + } + Assert.Equal(expected, actual); + } } }
Vulnerability mechanics
Root cause
"Insufficient escaping of environment variable values when constructing Docker CLI arguments allows an attacker to inject arbitrary Docker command-line options."
Attack vector
An attacker who can control the value of an environment variable used in a GitHub Actions workflow that employs container actions, job containers, or service containers can inject characters that break out of the intended `-e KEY=VALUE` argument. The old `EscapeString` method only performed a naive `\` and `"` replacement, which did not account for .NET's command-line parsing rules for backslashes preceding quotes. By crafting an environment value containing a trailing backslash or a carefully placed backslash-quote sequence, the attacker can close the surrounding quote and append additional Docker flags (e.g., `--privileged`, `--volume`), altering the container's behavior or escalating privileges. The bug is triggered when the runner builds the `docker create` command line and passes the unsafely escaped string to the Docker CLI.
Affected code
The vulnerability resides in `src/Runner.Worker/Container/DockerUtil.cs` in the `EscapeString` and `CreateEscapedOption` methods, and in `src/Runner.Worker/Container/DockerCommandManager.cs` in the `DockerCreate` method and the `ExecuteDockerCommandAsync` overloads [patch_id=1641338]. The old `EscapeString` used `value.Replace("\\", "\\\\").Replace("\"", "\\\"")` which was insufficient. The old `CreateEscapedOption(string flag, string key)` only escaped the key, while the value was passed separately through a process environment dictionary, allowing the value to bypass escaping entirely.
What the fix does
The patch introduces two compiled regular expressions (`QuoteEscape` and `EndOfStringEscape`) in `DockerUtil.cs` [patch_id=1641338] to correctly handle .NET's command-line argument escaping rules. The new `EscapeString` method first doubles any backslashes that precede a double quote, then doubles any trailing backslashes that would otherwise escape the closing quote, and finally wraps the entire value in quotes. Additionally, a new overload `CreateEscapedOption(string flag, string key, string value)` was added so that the environment variable value is included in the escaped string (as `key=value`) rather than being passed separately via a process environment dictionary. The `DockerCommandManager.cs` was updated to stop passing environment variables through the process invoker's environment dictionary and instead embed them directly in the Docker CLI arguments [patch_id=1641338], eliminating the injection vector.
Preconditions
- configThe workflow must use container actions, job containers, or service containers.
- inputAn environment variable in the workflow must include untrusted user input as its value.
- configThe runner version must be prior to 2.296.2, 2.293.1, 2.289.4, 2.285.2, or 2.283.4.
Generated on May 23, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5News mentions
0No linked articles in our index yet.