VYPR
High severityNVD Advisory· Published Oct 25, 2022· Updated Apr 23, 2025

GitHub Actions Runner vulnerable to Docker Command Escaping

CVE-2022-39321

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.

PackageAffected versionsPatched versions
actions/runnerGitHub Actions
>= 2.294.0, < 2.296.22.296.2
actions/runnerGitHub Actions
>= 2.290.0, < 2.293.12.293.1
actions/runnerGitHub Actions
>= 2.286.0, < 2.289.42.289.4
actions/runnerGitHub Actions
>= 2.284.0, < 2.285.22.285.2
actions/runnerGitHub Actions
< 2.283.42.283.4

Affected products

2

Patches

7
436989949b1b

Update releaseVersion

https://github.com/actions/runnerThomas BoopSep 20, 2022via osv
2 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
    
7f6b9e55d60c

Update releaseVersion

https://github.com/actions/runnerThomas BoopSep 20, 2022via osv
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
    
3c64f263211f

Update releaseVersion

https://github.com/actions/runnerThomas BoopSep 20, 2022via osv
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
    
a78e6e059486

Update releaseVersion

https://github.com/actions/runnerThomas BoopSep 20, 2022via osv
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
    
21c30edf1e7a

Update releaseVersion

https://github.com/actions/runnerThomas BoopSep 8, 2022via osv
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
    
ed191b78ae33

Port hotfix to main branch (#2108)

https://github.com/actions/runnerThomas BoopSep 9, 2022via ghsa-ref
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);
    +        }
         }
     }
    
96f4f5e76e5b

2.296.2 Release notes (#2107)

https://github.com/actions/runnerThomas BoopSep 8, 2022via ghsa-ref
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

5

News mentions

0

No linked articles in our index yet.