Skip to content

Commit fe03566

Browse files
authored
Merge pull request #233 from AikidoSec/main
Merge main
2 parents 98d75f2 + 89c647a commit fe03566

File tree

9 files changed

+228
-19
lines changed

9 files changed

+228
-19
lines changed

.vscode/launch.json

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,59 @@
11
{
2-
"version": "0.2.0",
3-
"configurations": [
4-
{
5-
"name": "Debug DotNetCore.Sample.App",
6-
"type": "coreclr",
7-
"request": "launch",
8-
"preLaunchTask": "build DotNetCore.Sample.App",
9-
"program": "${workspaceFolder}/sample-apps/DotNetCore.Sample.App/bin/Debug/net10.0/DotNetCore.Sample.App.dll",
10-
"args": [],
11-
"cwd": "${workspaceFolder}/sample-apps/DotNetCore.Sample.App",
12-
"stopAtEntry": false,
13-
"console": "integratedTerminal",
14-
"env": {
15-
"ASPNETCORE_ENVIRONMENT": "Development",
16-
"DOTNET_ENVIRONMENT": "Development"
17-
}
18-
}
19-
]
2+
"version": "0.2.0",
3+
"configurations": [
4+
{
5+
"name": "Attach vsdbg ssh",
6+
"type": "coreclr",
7+
"request": "attach",
8+
"pipeTransport": {
9+
"pipeCwd": "${workspaceFolder}",
10+
"pipeProgram": "/usr/bin/ssh",
11+
"pipeArgs": [
12+
"-p",
13+
"10022",
14+
"root@localhost"
15+
],
16+
"debuggerPath": "/vsdbg/vsdbg"
17+
},
18+
"requireExactSource": false,
19+
"justMyCode": false
20+
},
21+
{
22+
"name": "Attach .NET (pick process)",
23+
"type": "coreclr",
24+
"request": "attach",
25+
"processId": "${command:pickProcess}",
26+
"justMyCode": false
27+
},
28+
{
29+
"name": "Launch DotNetCore.Sample.App",
30+
"type": "coreclr",
31+
"request": "launch",
32+
"preLaunchTask": "build DotNetCore.Sample.App",
33+
"program": "${workspaceFolder}/sample-apps/DotNetCore.Sample.App/bin/Debug/net10.0/DotNetCore.Sample.App.dll",
34+
"args": [],
35+
"cwd": "${workspaceFolder}/sample-apps/DotNetCore.Sample.App",
36+
"stopAtEntry": false,
37+
"console": "integratedTerminal",
38+
"env": {
39+
"ASPNETCORE_ENVIRONMENT": "Development",
40+
"DOTNET_ENVIRONMENT": "Development"
41+
}
42+
},
43+
{
44+
"name": "Launch UmbracoSampleApp",
45+
"type": "coreclr",
46+
"request": "launch",
47+
"preLaunchTask": "build UmbracoSampleApp",
48+
"program": "${workspaceFolder}/e2e/sample-apps/UmbracoSampleApp/bin/Debug/net10.0/UmbracoSampleApp.dll",
49+
"args": [],
50+
"cwd": "${workspaceFolder}/e2e/sample-apps/UmbracoSampleApp",
51+
"stopAtEntry": false,
52+
"console": "integratedTerminal",
53+
"env": {
54+
"ASPNETCORE_ENVIRONMENT": "Development",
55+
"DOTNET_ENVIRONMENT": "Development"
56+
}
57+
}
58+
]
2059
}

.vscode/tasks.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@
1414
"kind": "build",
1515
"isDefault": true
1616
}
17+
},
18+
{
19+
"label": "build UmbracoSampleApp",
20+
"type": "process",
21+
"command": "dotnet",
22+
"args": [
23+
"build",
24+
"${workspaceFolder}/e2e/sample-apps/UmbracoSampleApp/UmbracoSampleApp.csproj"
25+
],
26+
"problemMatcher": "$msCompile"
1727
}
1828
]
1929
}

Aikido.Zen.Core/Helpers/HttpHelper.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ public static async Task<HttpDataResult> ReadAndFlattenHttpDataAsync(
8484
LogHelper.ErrorLog(Agent.Logger, $"caught error while parsing body: {e.Message}");
8585
}
8686

87+
// Decode percent-encoded values
88+
UserInputHelper.DecodeUriValues(result);
89+
8790
return new HttpDataResult
8891
{
8992
FlattenedData = result,

Aikido.Zen.Core/Helpers/UserInputHelper.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Linq;
34
using Aikido.Zen.Core.Models;
45
using Microsoft.Net.Http.Headers;
56

@@ -10,6 +11,25 @@ namespace Aikido.Zen.Core.Helpers
1011
/// </summary>
1112
public static class UserInputHelper
1213
{
14+
private const int MaxDecodeUriPasses = 2;
15+
16+
/// <summary>
17+
/// Decodes percent-encoded values in place (e.g. who%61mi => whoami).
18+
/// </summary>
19+
/// <param name="values">Dictionary containing user input values.</param>
20+
public static void DecodeUriValues(IDictionary<string, string> values)
21+
{
22+
if (values == null || values.Count == 0)
23+
{
24+
return;
25+
}
26+
27+
foreach (var key in values.Keys.ToList())
28+
{
29+
values[key] = DecodeUriComponent(values[key]);
30+
}
31+
}
32+
1333
/// <summary>
1434
/// Extracts the source of the user input from the path.
1535
/// </summary>
@@ -107,5 +127,28 @@ public static bool IsMultipart(string contentType, out string boundary)
107127
boundary = parsedContentType.Boundary.Value;
108128
return isMultipart;
109129
}
130+
131+
private static string DecodeUriComponent(string input)
132+
{
133+
string decoded = input;
134+
135+
if (string.IsNullOrEmpty(input))
136+
{
137+
return decoded;
138+
}
139+
140+
for (int i = 0; i < MaxDecodeUriPasses; i++)
141+
{
142+
string next = Uri.UnescapeDataString(decoded);
143+
if (next == decoded)
144+
{
145+
break;
146+
}
147+
148+
decoded = next;
149+
}
150+
151+
return decoded;
152+
}
110153
}
111154
}

Aikido.Zen.Core/Vulnerabilities/ShellInjectionDetector.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public class ShellInjectionDetector
1616
private static readonly char[] separators = { ' ', '\t', '\n', ';', '&', '|', '(', ')', '<', '>' };
1717

1818
// Define dangerous characters and commands as static fields
19-
private static readonly char[] dangerousChars = { '#', '!', '"', '$', '&', '\'', '(', ')', '*', ';', '<', '=', '>', '?', '[', '\\', ']', '^', '`', '{', '|', '}', ' ', '\n', '\t', '~', '%' };
19+
private static readonly char[] dangerousChars = { '#', '!', '"', '$', '&', '\'', '(', ')', '*', ';', '<', '=', '>', '?', '[', '\\', ']', '^', '`', '{', '|', '}', ' ', '\n', '\t', '~' };
2020
private static readonly string[] dangerousCommands = { "sleep", "shutdown", "reboot", "poweroff", "halt", "ifconfig", "chmod", "chown", "ping", "ssh", "scp", "curl", "wget", "telnet", "kill", "killall", "rm", "mv", "cp", "touch", "echo", "cat", "head", "tail", "grep", "find", "awk", "sed", "sort", "uniq", "wc", "ls", "env", "ps", "who", "whoami", "id", "w", "df", "du", "pwd", "uname", "hostname", "netstat", "passwd", "arch", "printenv", "logname", "pstree", "hostnamectl", "set", "lsattr", "killall5", "dmesg", "history", "free", "uptime", "finger", "top", "shopt", ":" };
2121
private static readonly char[] dangerousCharsInsideDoubleQuotes = { '$', '`', '\\', '!' };
2222

Aikido.Zen.Test/HttpHelperTests.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,44 @@ private class TestCase
104104
public object ExpectedParsedBody { get; set; }
105105
}
106106

107+
[Test]
108+
public async Task ReadAndFlattenHttpDataAsync_ShouldDecodePercentEncodedUserInputValues()
109+
{
110+
// Arrange
111+
var routeParams = new Dictionary<string, string> { { "command", "who%61mi" } };
112+
var queryParams = new Dictionary<string, string> {
113+
{ "path", "%2e%2e%2fetc%2fpasswd" },
114+
{ "emoji", "%F0%9F%98%80" },
115+
{ "double", "%2577%2568%256f%2561%256d%2569" },
116+
{ "invalid", "%E0%A4%A" }
117+
};
118+
var headers = new Dictionary<string, string> { { "X-Custom", "a+b%2Bc" } };
119+
var cookies = new Dictionary<string, string> { { "session", "abc%31%32%33" } };
120+
const string body = "{\"cmd\":\"who%61mi\",\"literal\":\"a+b\"}";
121+
using var bodyStream = new MemoryStream(Encoding.UTF8.GetBytes(body));
122+
123+
// Act
124+
var result = await HttpHelper.ReadAndFlattenHttpDataAsync(
125+
routeParams,
126+
queryParams,
127+
headers,
128+
cookies,
129+
bodyStream,
130+
"application/json",
131+
bodyStream.Length);
132+
133+
// Assert
134+
Assert.That(result.FlattenedData["route.command"], Is.EqualTo("whoami"));
135+
Assert.That(result.FlattenedData["query.path"], Is.EqualTo("../etc/passwd"));
136+
Assert.That(result.FlattenedData["query.emoji"], Is.EqualTo("\U0001F600"));
137+
Assert.That(result.FlattenedData["query.double"], Is.EqualTo("whoami"));
138+
Assert.That(result.FlattenedData["query.invalid"], Is.EqualTo("%E0%A4%A"));
139+
Assert.That(result.FlattenedData["headers.X-Custom"], Is.EqualTo("a+b+c"));
140+
Assert.That(result.FlattenedData["cookies.session"], Is.EqualTo("abc123"));
141+
Assert.That(result.FlattenedData["body.cmd"], Is.EqualTo("whoami"));
142+
Assert.That(result.FlattenedData["body.literal"], Is.EqualTo("a+b"));
143+
}
144+
107145
[Test]
108146
public void ToJsonObj_ShouldHandleTrueFalseNull()
109147
{

sample-apps/DotNetCore.Sample.App/Controllers/ShellInjectionController.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
using System;
12
using System.Diagnostics;
23
using System.Runtime.InteropServices;
34
using System.Text;
45
using System.Threading.Tasks;
56
using Microsoft.AspNetCore.Mvc;
7+
using DotNetCore.Sample.App.Models;
68

79
namespace DotNetCore.Sample.App.Controllers
810
{
@@ -14,6 +16,8 @@ namespace DotNetCore.Sample.App.Controllers
1416
[Route("shell-injection")]
1517
public class ShellInjectionController : ControllerBase
1618
{
19+
private const int MaxDecodeUriPasses = 2;
20+
1721
/// <summary>
1822
/// Executes a shell command provided in the 'cmd' query parameter.
1923
/// On Windows, it attempts to use WSL via 'cmd.exe /c wsl'.
@@ -48,8 +52,23 @@ public async Task<IActionResult> ExecuteRouteCommand(string command)
4852
return await ExecuteCommandInternal(command);
4953
}
5054

55+
[HttpPost("/api/execute")]
56+
public async Task<IActionResult> ExecuteCommandPost([FromBody] CommandRequest request)
57+
{
58+
var command = request.UserCommand;
59+
60+
if (string.IsNullOrEmpty(command))
61+
{
62+
return BadRequest("Command is required");
63+
}
64+
65+
return await ExecuteCommandInternal(command);
66+
}
67+
5168
private async Task<IActionResult> ExecuteCommandInternal(string command)
5269
{
70+
command = DecodeUriComponent(command);
71+
5372
var processStartInfo = new ProcessStartInfo
5473
{
5574
RedirectStandardOutput = true,
@@ -133,5 +152,28 @@ private async Task<IActionResult> ExecuteCommandInternal(string command)
133152
return BadRequest($"Error executing command: {ex.Message}\nStackTrace:{ex.StackTrace}");
134153
}
135154
}
155+
156+
private static string DecodeUriComponent(string input)
157+
{
158+
string decoded = input;
159+
160+
if (string.IsNullOrEmpty(input))
161+
{
162+
return decoded;
163+
}
164+
165+
for (int i = 0; i < MaxDecodeUriPasses; i++)
166+
{
167+
string next = Uri.UnescapeDataString(decoded);
168+
if (next == decoded)
169+
{
170+
break;
171+
}
172+
173+
decoded = next;
174+
}
175+
176+
return decoded;
177+
}
136178
}
137179
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace DotNetCore.Sample.App.Models
2+
{
3+
public class CommandRequest
4+
{
5+
public string? UserCommand { get; set; }
6+
}
7+
}

sample-apps/DotNetFramework.Sample.App/Controllers/ShellInjectionController.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ namespace DotNetFramework.Sample.App.Controllers
1616
[RoutePrefix("api/shell-injection")]
1717
public class ShellInjectionController : ApiController
1818
{
19+
private const int MaxDecodeUriPasses = 2;
20+
1921
/// <summary>
2022
/// Executes a shell command provided in the 'cmd' query parameter.
2123
/// On Windows, it attempts to use WSL via 'cmd.exe /c wsl'.
@@ -32,6 +34,8 @@ public async Task<IHttpActionResult> ExecuteCommand()
3234
return BadRequest("Command parameter 'cmd' is required.");
3335
}
3436

37+
command = DecodeUriComponent(command);
38+
3539
var processStartInfo = new ProcessStartInfo
3640
{
3741
RedirectStandardOutput = true,
@@ -93,5 +97,28 @@ public async Task<IHttpActionResult> ExecuteCommand()
9397
return BadRequest($"Error executing command: {ex.Message} StackTrace:{ex.StackTrace}");
9498
}
9599
}
100+
101+
private static string DecodeUriComponent(string input)
102+
{
103+
string decoded = input;
104+
105+
if (string.IsNullOrEmpty(input))
106+
{
107+
return decoded;
108+
}
109+
110+
for (int i = 0; i < MaxDecodeUriPasses; i++)
111+
{
112+
string next = Uri.UnescapeDataString(decoded);
113+
if (next == decoded)
114+
{
115+
break;
116+
}
117+
118+
decoded = next;
119+
}
120+
121+
return decoded;
122+
}
96123
}
97124
}

0 commit comments

Comments
 (0)