MRMCD23: DotNetNo
Task
I don’t know the exact wording, but it went like this:
Our OpSec department is very strict with network permissions and doesn’t give us anything more than the port 443 to access the server. But our team requires SSH access, so we build our own HTTP to shell server. But we’ve secured it up, there’s no way you can access the flag in
/opt/flag.txt
.Code of the server is attached.
The server was programmed in C# and ran on the .NET framework. The interface to it was very simple: One could send POST requests to various endpoints which modeled real UNIX commands. For example using the awesome httpie command.
$ http post "https://ctf.mrmcd.net/nonononono/Command/cat" "FilePath=/etc/hostname"
271929d87fb4
$ http post "https://ctf.mrmcd.net/nonononono/Command/which" "Command=ls"
/bin/ls
$ http post "https://ctf.mrmcd.net/nonononono/Command/cat" "FilePath=/opt/flag.txt"
HTTP/1.1 400 Bad Request
{
[…]
"errors": {
[…]
"FilePath": [
"Catting files from /opt/ is forbidden"
]
},
[…]
}
solution
TL;DR: .NET and UNIX have different opinions on how paths are resolved. With this
we can circumvent the imposed access-restrictions on the /opt/
folder.
Exploration
Let us first explore how the server works.
The server exposes (as can be seen above) multiple endpoints, which correspond to
different commands. Each endpoint uses a Request
C# Model in order to verify if the
input is valid. If it is, the corresponding command gets executed with the parsed
and validated input.
The Request
Model for the cat
command looks like this
public class CatRequest : CommandRequest, IValidatableObject
{
private static readonly List<string> forbiddenPathPrefixes = new List<string>{
"/opt/", // Flag will be here somewhere
"/proc/" // Good practice
};
[Required]
[RegularExpression(@"^[\w-/.]+$")]
public string FilePath { get; set; }
public Type CommandType => typeof(CatCommand);
// I've no idea why everything from this point on is lower-case ¯\_(ツ)_/¯
public ienumerable<validationresult> validate(validationcontext validationcontext)
{
foreach (var forbiddenprefix in forbiddenpathprefixes)
{
if (path.getfullpath(filepath).startswith(forbiddenprefix))
{
yield return new validationresult($"catting files from {forbiddenprefix} is forbidden", new[] { nameof(filepath) });
}
}
}
}
From this we could derive, that the input to the cat
endpoint is a parameter called
FilePath
which needs to match the regex ^[\w-/.]+$
(so no funky stuff with escape
codes) and it isn’t allowed to start with /opt/
nor /proc/
after the path has
been resolved.
In this code snipped, we can see the reason, why our naive approach of retrieving the
flag (as seen above) did not work. It would directly be filtered out by the /opt/
pattern.
The interesting point for our approach here is the GetFullPath
1 method. If we for
example try to access the flag using relative paths, it doesn’t work either:
$ http post "https://ctf.mrmcd.net/nonononono/Command/cat" "FilePath=/var/../opt/flag.txt"
HTTP/1.1 400 Bad Request
[…]
So the GetFullPath
seems to resolve relative paths too. But does it do this
correctly?
How Unix resolves paths
If we get a path with a double dot (..
) in it, the naive approach would be to just
split the path into its nodes, remove the double dots together with the nodes before
and stitch it all together.
/var/spool/mail/../test
= ["var", "spool", "mail", "..", "test"]
= ["var", "spool", "test"]
= /var/spool/test
But let’s see what happens if we plunge that into realpath
$ realpath "/var/spool/mail/../test"
/var/test
Well. This is probably unexpected for most. The explanation is relatively simple: the
folder /var/spool/mail
is a symlink to /var/mail
. Paths in UNIX are resolved
iteratively, so if we hit during one of the steps on a symlink, we follow it. All
relative descriptors will then work from there.
/var/spool/mail/../test
1. /var
2. /spool
3. /mail ->
/var/mail
4. /.. ->
/var
5. /test
=> looking at file in /var/test
This is also the behaviour when using e.g. Pythons pathlib.Path.resolve()
.
Getting the flag
But how does .NET GetFullPath
resolve this? Well not well, actually. Probing around
a bit (there are a few symlink folders on Linux which are almost universal), we found
that it uses the naive approach to path resolving. Equipped with that knowledge the
problem was already on the ground2.
Using docker and some magic bash one-liner invocations we found a symlinked folder,
whose target path was higher up the tree than the link. Using a very small sample
size we determined, that this one exists on almost every Linux system:
/var/spool/mail → /var/mail
At this point we didn’t really believe that it would work (explained later), but it did. So yeah. Challenge cracked
$ http post "https://ctf.mrmcd.net/nonononono/Command/cat" "FilePath=/var/spool/mail/../../opt/flag.txt"
MRMCD2023{d154573r0u5d353r14l1z4710n}
MRMCD2023{d154573r0u5d353r14l1z4710n}
The intended solution
I might have hinted above, but this was not the intended solution - at all. What
wasn’t mentioned so far was the existence of a batch
endpoint. This one would take
a JSON input and execute these commands in-order of occurrence. But the way this was
implemented here was relatively faulty, because it was possible to fully circumvent
the above-mentioned validation checks due to a direct deserialization of the JSON
into the respective commands (instead of into Request
Models).
During the challenge we did see that endpoint, and it was pretty obvious that this was the intended attack vector. As such I didn’t believe into the approach we used in the end right upon the point we got the flag.
But well, with this solution I at least have a reason to open a bug report to the .NET team.
EDIT Well so much regarding the bug report. The issue is known by the .NET team and
instead of fixing their GetFullPath
implementation, they introduced a new
ResolveLinkTarget
3 method ??? (as mentioned in the GitHub issue4)
Solved by Tyalie