September 12, 2026
Discord ecosystem security
Have you wondered how secure these bots that most Discord users daily use actually are? Or how they can be hacked?
By Vz0n
10 min read
Back in time, on 2023 and the beginning of 2024, that question was partially answered by the youtuber No Text To Speech and the researcher xyzeva in a quick video series where the researcher found pretty basic security mistakes on Captcha.bot, Double Counter, Maki, Carl and Dyno. On the first the dev trusted that nobody would find a unprotected debug endpoint that gives you admin access, on the other there were credentials stored in the public assets, and on the rest; they didn't have CSRF protection on some important endpoints. Clearly, that is not a good signal.
The concerning fact is that, as I said, these were pretty basic security issues that anybody with minimal knowledge could exploit, and clearly the researcher didn't put too much effort on finding bugs across apps. That made me wonder, what if I dig deeper?
Something that no one noticed
A day after the CSRF discoveries video (https://www.youtube.com/watch?v=VFpWScRJ6Ac), I decided to take a look at the Dyno dashboard. There are several modules, but one interesting is the "Autoresponder" thing. This basically let you add words to which the bot will send a message when somebody's message contains it.
When you edit one, the dashboard sends this JSON to /api/server/:guild_id/autoresponder/edit/:responder_id
{
"command":{
"command":"test",
"response":"test",
"type":"message",
"allowedChannels":{
"channels":[]
},
"ignoredChannels":{
"channels":[]
},
"allowedRoles":{
"roles":[]
},
"ignoredRoles":{
"roles":[]
},
"reactions":{
"reactions":[]
},
"wildcard":false,
"guildId":":guild_id",
"embed":null,
"cooldown":null,
"choices":null,
"id":":autoresponse_id",
"createdAt":"<Date>",
"updatedAt":"<Date>"
}
}{
"command":{
"command":"test",
"response":"test",
"type":"message",
"allowedChannels":{
"channels":[]
},
"ignoredChannels":{
"channels":[]
},
"allowedRoles":{
"roles":[]
},
"ignoredRoles":{
"roles":[]
},
"reactions":{
"reactions":[]
},
"wildcard":false,
"guildId":":guild_id",
"embed":null,
"cooldown":null,
"choices":null,
"id":":autoresponse_id",
"createdAt":"<Date>",
"updatedAt":"<Date>"
}
}What are you thinking to do here? Do you think that there's nothing useful? If so, that's wrong. If you look at the guilIdfield, that identifies to which guilds the auto responder object belongs to, what if I point it to another guild?
Pointing it to another guild would mean that now the target guild owns the auto responder, and now every message that is equal to the command field (or has it, if wildcard is true), will trigger the auto responder. Which is a pretty nice spam and harassment primitive. Indeed, I tried to change the field and… it worked, the auto responder now appeared on my other target guild on which I have no business. This module is enabled by default, so there was a pretty big percentage of servers (out of the 9M on which Dyno was at the time) on which I can configure an auto responder to say whatever I want, to whatever word I want, and it can be made very annoying by putting multiple wildcard auto responders which matches a single letter (like 'a')
This bug was quickly fixed by the team of the bot. At this moment, my question was mainly; Why the other guys didn't catch this bug? Laziness? Focused only on CSRFs? Impact? I don't really know with exactitude, and for impact I highly doubt it as the featured Carl-bot (bot that we will talk about later) CSRF in the video only allowed sending a message as the bot.
But, there's more? Yes! there is much more.
It gets more funnier
When I started to play around with bots, I wasn't too active on hunting because I was more focused on university (fucking worst mistake of my life), but at the Q4 of 2025 I switched my focus on this and other things, as on the later was not doing anything more than wasting my time.
One of my main targets was Carl-bot, one of the most used Discord bots which at the date, had between 13M and 14M guild installs.
I decided to take a look at the reaction roles function of the bot, and when you create one, this is sent to /api/v1/servers/<guild_id>/reactionroles (summarizing types)
{
"channel_id":"<Snowflake>",
"content":"<String>",
"embed":"<DiscordEmbed>",
"pairs":[
{
"emoji":"<String>",
"roles":"Array<DiscordRole>"
}
],
"whitelist":"Array<?>",
"blacklist":"Array<?>",
"reaction_role_limit":"<Integer>",
"mode":"OneOf<1|2|3>",
"message_id":"<Snowflake>"
}{
"channel_id":"<Snowflake>",
"content":"<String>",
"embed":"<DiscordEmbed>",
"pairs":[
{
"emoji":"<String>",
"roles":"Array<DiscordRole>"
}
],
"whitelist":"Array<?>",
"blacklist":"Array<?>",
"reaction_role_limit":"<Integer>",
"mode":"OneOf<1|2|3>",
"message_id":"<Snowflake>"
}You may think on some things already:
- Changing the guild_id in the path: Won't work as the bot checks if you have the required manage permissions on the target server.
- Pointing the channel_id to a channel of another guild: A bit clever, but it won't work as the backend already checks if the channel belongs to your guild.
- Putting weird things: Won't work as the backend also checks if every type of the fields matches the expected type, so you can't put a string as snowflake.
Thing seems hardened, right? But even if so, I say: let's think! For me, the emoji field seems interesting, why? because seeing the Discord documentation, I saw that you need to put the field value in the API request URL:
How is this useful? The type validation only checks the type of the received data, but not the actual value. As emojiis a string, that could mean that I can add other things as emoji, and as this is URI path, that means I can also add dot segments (../) and query/fragments (#, ?) to the value, and thus rewrite the path where the PUTrequest is going to land.
But it says that values must be URL encoded, so the underlying library that the bot uses must do it… on such case, the function quote from the urllib.parse module seems frequently used, and this function has a quirk if you just call it without setting other parameters:
>>> from urllib.parse import quote
>>> quote("uwu/owo/../../#?")
'uwu/owo/../../%23%3F'>>> from urllib.parse import quote
>>> quote("uwu/owo/../../#?")
'uwu/owo/../../%23%3F'The dot segments survives! and about the other two chars; bots this big frequently implements load balancers/proxies to avoid getting rate limited by Discord, and there are weird cases where those chars are decoded and passed as it-is to the final Discord API request. I gambled that those two preconditions would met but now, where can I redirect this body-less PUTrequest to? The Discord official (and unofficial) docs facilitates that:
- Pin message: /channels/{channel.id}/messages/pins/{message.id}
- Add role to user: /guilds/{guild.id}/members/{user.id}/roles/{role.id}
- Ban member: /guilds/{guild.id}/bans/{user.id}
- Add member to thread: /channels/{channel.id}/thread-members/{user.id}
- Bulk modify global & guilds application commands: /applications/{application.id}/[guilds/{guild.id}]/commands (will delete every existing slash command)
- Clear voice channel status: /channels/{channel.id}/voice-status
- Add guild discovery subcategory: /guilds/{guild.id}/discovery-categories/{discovery_category.id}
From the attacker side, the most interesting thing is the Add role to user endpoint, which can grant to an arbitrary user a role whose hierarchy is below the bot's actual roles (for this bot, it happens that is usually above several or some staff roles).
Going back to the gambling, I tested the chance by putting this on the emoji field:
👍/../../../../../../channels/{channel.id}/messages/pins/{message.id}#👍/../../../../../../channels/{channel.id}/messages/pins/{message.id}#and… it worked. The bot pinned a message on another guild where I don't have any type of special permissions. That means I can also give myself roles that the bot can control.
This was not the only component where this bug was. The TagScript function {reactu: } also had this same flaw, which means that you can create a command that will grant you roles:
{reactu: <:02:1441484566547398787/../../../../../../guilds/{args(1)}/members/<your_id>/roles/{args(2)}#>}
Succesfully gave to you role {args(2)} in {args(1)}{reactu: <:02:1441484566547398787/../../../../../../guilds/{args(1)}/members/<your_id>/roles/{args(2)}#>}
Succesfully gave to you role {args(2)} in {args(1)}And just use it. Really funny!
https://www.youtube.com/watch?v=OcuhZF_dnXU
Again: This bot was on 13M-14M servers when I discovered this, and the flawed TagScript function was introduced back in 2018. Probably the vulnerability was there for several years and yet nobody found it.
What gives me more questions is that after the CSRF thing from eva, another guy at Sept 5, 2024 discovered an IDOR on the Embeds module which allowed you to send arbitrary messages as the bot to channels of other guilds and used it to fake a game shutdown (The Deadlock thing, if you remember). That incident took the attention of certified Discord bug hunters (those with the golden/green spoon) to peek at the bot and report other issues… and guess what!1!!!! not even those guys didn't found this fucking simple bug. Same questions: Were they lazy? Not enough skilled? Again, I don't really know.
The scary part
This specific technique that I used against Carl-bot, which is a path traversal on the HTTP context (I call it path cancel), also worked on other 18 bots, which gave me abilities ranging from mass information exfiltration to delete things like entire channels. I need to also point that most of those bots weren't precisely small. (> 100k guild installs)
Wait, info exfiltration? Yes, you read that well, and my best example of this is Discohook: Discohook is a widely used website that people use to design and send messages easily through Discord webhooks. It has its own Discord bot called "Discohook utils", which makes easier the job of sending messages through webhooks.
This site is open source, and by peeking at the source I already noticed something weird, specifically, at the /api/v1/log/webhooks/$webhookId/$webhookToken/messages/$messageId route code:
export const action = async ({ request, context, params }: ActionArgs) => {
const { webhookId, webhookToken, messageId } = zxParseParams(params, {
webhookId: snowflakeAsString().transform(String),
webhookToken: z.string(),
messageId: snowflakeAsString().transform(String),
});
const { type, threadId } = await zxParseJson(request, {
type: z.union([z.literal("send"), z.literal("edit"), z.literal("delete")]),
threadId: snowflakeAsString().transform(String).optional(),
// components: z
// .object({
// id: z.string().regex(/\d+/),
// // row: z.number().min(0).max(4),
// // col: z.number().min(0).max(4),
// flow: ZodFlow,
// })
// .array()
// .optional(),
});
const headers = await getBucket(request, context, "messageLog");
const now = new Date();
const messageIdSnowflake = Snowflake.parse(messageId, DISCORD_EPOCH);
if (type === "send" && now.getTime() - messageIdSnowflake.timestamp > 15000) {
// Allow 15 seconds to send the log request
// This disallows people from logging any old message sent by a webhook
// they have access to (and reduces our server's API calls in such cases)
throw json({ message: "Message is too old" }, { status: 400, headers });
}
const rest = new REST({ api: context.env.DISCORD_PROXY_API }).setToken(
context.env.DISCORD_BOT_TOKEN,
);
const userId = await getUserId(request, context);
let message: APIMessage | undefined;
if (type === "delete") {
// Make sure the user doesn't log that they deleted a message that still exists
const deleted = await getWebhookMessage(
webhookId,
webhookToken,
messageId,
threadId,
rest,
);
if (deleted.id) {
throw json({ message: "Message still exists" }, { status: 400, headers });
}
} else {
message = await getWebhookMessage(
webhookId,
webhookToken,
messageId,
threadId,
rest,
);
if (!message.id) {
throw json(message, 404);
}
if (isComponentsV2(message)) {
// We currently do not support logging these messages out of an abundance of caution
throw json(
{ message: "Message is not loggable" },
{ status: 400, headers },
);
}
if (type === "edit") {
if (!message.edited_timestamp) {
throw json(
{ message: "Message has never been edited" },
{ status: 400, headers },
);
}
if (
now.getTime() - new Date(message.edited_timestamp).getTime() >
15000
) {
// Allow 15 seconds to send the log request
// This disallows people from logging any old message sent by a webhook
// they have access to (and reduces our server's API calls in such cases)
throw json(
{ message: "Message was edited too long ago" },
{ status: 400, headers },
);
}
}
}
// ... [snip]
}export const action = async ({ request, context, params }: ActionArgs) => {
const { webhookId, webhookToken, messageId } = zxParseParams(params, {
webhookId: snowflakeAsString().transform(String),
webhookToken: z.string(),
messageId: snowflakeAsString().transform(String),
});
const { type, threadId } = await zxParseJson(request, {
type: z.union([z.literal("send"), z.literal("edit"), z.literal("delete")]),
threadId: snowflakeAsString().transform(String).optional(),
// components: z
// .object({
// id: z.string().regex(/\d+/),
// // row: z.number().min(0).max(4),
// // col: z.number().min(0).max(4),
// flow: ZodFlow,
// })
// .array()
// .optional(),
});
const headers = await getBucket(request, context, "messageLog");
const now = new Date();
const messageIdSnowflake = Snowflake.parse(messageId, DISCORD_EPOCH);
if (type === "send" && now.getTime() - messageIdSnowflake.timestamp > 15000) {
// Allow 15 seconds to send the log request
// This disallows people from logging any old message sent by a webhook
// they have access to (and reduces our server's API calls in such cases)
throw json({ message: "Message is too old" }, { status: 400, headers });
}
const rest = new REST({ api: context.env.DISCORD_PROXY_API }).setToken(
context.env.DISCORD_BOT_TOKEN,
);
const userId = await getUserId(request, context);
let message: APIMessage | undefined;
if (type === "delete") {
// Make sure the user doesn't log that they deleted a message that still exists
const deleted = await getWebhookMessage(
webhookId,
webhookToken,
messageId,
threadId,
rest,
);
if (deleted.id) {
throw json({ message: "Message still exists" }, { status: 400, headers });
}
} else {
message = await getWebhookMessage(
webhookId,
webhookToken,
messageId,
threadId,
rest,
);
if (!message.id) {
throw json(message, 404);
}
if (isComponentsV2(message)) {
// We currently do not support logging these messages out of an abundance of caution
throw json(
{ message: "Message is not loggable" },
{ status: 400, headers },
);
}
if (type === "edit") {
if (!message.edited_timestamp) {
throw json(
{ message: "Message has never been edited" },
{ status: 400, headers },
);
}
if (
now.getTime() - new Date(message.edited_timestamp).getTime() >
15000
) {
// Allow 15 seconds to send the log request
// This disallows people from logging any old message sent by a webhook
// they have access to (and reduces our server's API calls in such cases)
throw json(
{ message: "Message was edited too long ago" },
{ status: 400, headers },
);
}
}
}
// ... [snip]
}The webhookIdand messageIdvariables are properly validated, but the webhookTokenshould only be a string. This token is part of the URI of the Discord API request to fetch/send webhook messages. It's almost the same flaw that Carl had.
Now, this token is passed to the getWebhookMessage function using a Discord REST client object initialized with the Discohook Utils bot token, and that function does the following
export const getWebhookMessage = async (
webhookId: string,
webhookToken: string,
messageId: string,
threadId?: string,
rest?: REST,
) => {
const query = threadId
? new URLSearchParams({ thread_id: threadId })
: undefined;
const data = await discordRequest<RESTGetAPIWebhookWithTokenMessageResult>(
RequestMethod.Get,
`/webhooks/${webhookId}/${webhookToken}/messages/${messageId}`,
{ query, rest },
);
return data;
}export const getWebhookMessage = async (
webhookId: string,
webhookToken: string,
messageId: string,
threadId?: string,
rest?: REST,
) => {
const query = threadId
? new URLSearchParams({ thread_id: threadId })
: undefined;
const data = await discordRequest<RESTGetAPIWebhookWithTokenMessageResult>(
RequestMethod.Get,
`/webhooks/${webhookId}/${webhookToken}/messages/${messageId}`,
{ query, rest },
);
return data;
}Welp uhh… the token is directly used to create the API request path. As this is a parameter taken from the URL, URL encoding the dot segments and the other chars is needed and depending of certain conditions it may or may not work, but let's test it.
Going back to the route handler code, we saw that if the returned object didn't have a id field, it will just return the plain object that getWebhookMessage got. The Discord API actually has several endpoints that don't have that field, and are those that returns chunks of things, like the channel messages endpoint ( /channels/{channel.id}/messages ). If I can redirect the GET request, that means that I can fetch messages from every channel that the bot can see among a lot of other things, because the bot will just return the plain Discord response as it doesn't have anid field. I tried to redirect it… and it worked:
It's a common thing to assign those bots permissions to see private channels, which I was able to read with this. There's also a Discord endpoint that allows you to see every guild that has installed the bot, and it doesn't care if your guild is discoverable or private. The bot was sitting on 500k guilds when I discovered this.
So far now we have seen examples on websites that controls Discord bots, but it doesn't means that the bot is invulnerable to this if it doesn't have a web interface. RaidProtect (near 320k guild installs at the time) had no dashboard and I still discovered this same flaw at a modal configuration interface. It allowed to use a webhook to use for log messages, and it was only validating that the value started with https://discord.com/api/webhooks/... which I easily bypassed using https://discord.com/api/webhooks/AsAA2w/../../v10/{endpoint} . That not only allowed me to send log messages as the bot to any channel but also send aPOST request to any endpoint which is body-less or accepts a structure similar to the Create message one. The last thing allows me to do things like triggering the typing indicator, create roles with the default values and other things. It was limited compared to other cases but it shows the point.
The other bots that also had this flaw (in their own flavor) were Sapphire, Appy, Wick, Xenon, Welcomer, DraftBot, Maki, and other medium/small sized bots.
Another things
Remember the "a bit clever" trick of changing the channel id to send a message to a channel of another guild? Welp, that trick actually worked on some bots like Koya (2M guild installs). The subset was actually small compared to the path cancel one but it's still concerning given the size of the bots and where they are used (for example, one of the bots on which that worked it's used on the official HackTheBox server).
This article shows that Discord bots are just another piece of software which is not immune to vulnerabilities. You should only grant the permissions that the bot truly needs to function to minimize impact if someday they get hacked. And if you're a bot dev, please properly audit your code and check what you're writing with security in mind. If you bot gets big enough and has a simple flaw like the ones showed in here, you're exposing all your users to get their guilds spammed or raided in the worst case.
Getting the token of the bot is not the only way that it could be hacked. Every one of the commands, modules and components is an entry point for any type of bugs.
If you want to see more of the bugs that I found across popular Discord apps and websites, you can check my GitHub repository at https://github.com/Vz0n/discord_hacking.