Martin's Blog

Synology findhostd: Auditing an Unauthenticated Endpoint Running as Root

This started with a simple question to a Claude model: “My NAS is at 192.168.0.103. What version is it running?”

I wanted to see how far I could push a conversational AI model to perform real security research on my personal Synology NAS. The setup was a GitHub Copilot CLI with Claude Opus 4.6 running in a terminal agent (Ubuntu) with access to standard tools, plus a handful of custom skills I had written previously: decompile-idapro (drives IDA Pro 9.3 headless decompilation), binary-analysis (triage and inspection of ELF/PE binaries), and find-vulns (structured vulnerability scanning with CWE tagging). The skills give the model capabilities it would not have out of the box, but the research direction was entirely unscripted – just natural language prompts, one leading into the next, following whatever thread looked interesting.

The session went from fingerprinting to firmware download to unpacking to decompiling to vulnerability hunting to writing proof-of-concept exploits, all driven by conversational prompts. Each finding informed the next question. The whole thing was an unscripted chain of “what’s behind that door?”

The result: 40 findings across 4 binaries, including a pre-auth admin takeover validated on live hardware with working PoC scripts. The parser survived 15.4 million fuzzing executions without a single crash – the risks are all logic-level.

Update 2026-04-16: Synology Security Team

The Synology Security Team responded within a day of my reporting:

  • The QuickConf behavior described is part of the initial device setup mechanism and is considered expected by design.
  • The information disclosure via discovery protocol is also intended functionality for device identification within the local network.

Again, the goal of this exercise was not to uncover critical issues such as RCEs (though that would, of course, have been welcome), but rather to assess how far an autonomous agent equipped with a strong AI model could progress.

How It Started

The first prompt was just device identification. Claude probed ports 5000/5001 on my NAS, hit the unauthenticated /webman/info.cgi endpoint, and came back with: DS218+, DSM 7.2, build 72803, serial 1990xxxxxxxxx, platform synology_apollolake_218+. It then checked Synology’s auto-update RSS feed to confirm this was the latest version available for my model.

I asked it to download the firmware. It constructed the CDN URL from the model identifier, downloaded the 363 MB .pat file, and when standard extraction tools failed (Synology uses a proprietary encrypted format with magic 0x63adbeef), it found and used a Docker-based extraction tool that wraps Synology’s own scemd binary.

With the filesystem unpacked – 23,342 files, kernel 4.4.302+, 16 .spk packages – I wanted to understand the unauthenticated info.cgi endpoint that had just given up so much information. Claude traced the symlink: info.cgi points to /usr/syno/bin/synosearchagent, a 58 KB multi-call binary. I asked it to decompile the binary. It used IDA Pro 9.3 in headless mode and produced 49 decompiled C functions.

The security audit of synosearchagent found 10 vulnerabilities (1 critical XSS, 2 high), but more importantly, it identified the real attack surface: libfindhost.so.1, the UDP packet parsing library that handles the undocumented FindHost discovery protocol on port 9999. That library got decompiled next (81 functions, 9 vulnerabilities), which led to the daemon that calls it: findhostd.

Each step was a natural follow-up to the previous finding. “What does this binary do?” led to “Who calls this library?” led to “This daemon runs as root and has no auth on one of its handlers.”

findhostd

findhostd is Synology’s LAN device discovery and initial configuration daemon. It runs as root, binds three UDP sockets (ports 9999, 9998, 9997), and dispatches incoming packets by type. The binary is 37 KB, ELF x86-64 PIE, stripped. IDA Pro 9.3 decompiled it into 59 functions.

The supporting library libfindhost.so.1 (81 functions) handles the actual packet parsing – a TLV format with an 8-byte magic header, 79 field types dispatched via binary search through a hardcoded descriptor table, and optional Curve25519 encryption via libsodium.

Architecture

findhostd binds three UDP sockets (ports 9999, 9998, 9997), runs a select() The daemon runs a select() loop and dispatches incoming packets by type:

// sub_6D50 -- packet type dispatch
if ( v7 == 1 )  // plaintext magic
{
    if ( FHOSTPacketReadPlain(v11, a2, a1, s) <= 0 || !dword_12570 && v14 != 1 )
        return -1;  // plaintext only allows type 1 unless -c flag
}
else if ( v7 != 2 || FHOSTPacketReadEncrypted(v11, a2, a1, s) <= 0 )
{
    return -1;  // encrypted magic required for type 2
}

switch ( v14 )  // v14 = packet type from field 0x01
{
    case 1:   // -> sub_5B60: info request (no auth)
    case 3:   // -> sub_5F60: network reconfig (PAM auth)
    case 4:   // -> sub_5940: shutdown (PAM auth)
    case 12:  // -> sub_5A60: QuickConf (NO auth)
}

Types 3 and 4 call sub_7F10, which performs PAM authentication against the local shadow database. Type 1 has no authentication at all – it is the discovery response. Type 12, QuickConf, also has no authentication. That is the interesting one.

The Critical: You Can Set the Admin Password Without Authenticating

QuickConf is Synology’s “initial setup over the network” protocol. When you unbox a NAS and open Synology Assistant, the assistant sends a QuickConf packet that sets the admin password, hostname, and network configuration. The daemon processes it and marks the device as configured.

Here is the entire handler:

// sub_5A60 -- QuickConf handler (packet type 12)
if ( sub_7DE0() == 1 )  // already configured?
{
    fwrite("QuickConf already Done\n", ...);
    return;             // yes -- reject
}
// NOT configured -- no auth check, proceed directly:
sub_5740(a1, &v5);      // -> sub_52F0: set admin password, hostname, network
sub_7D50(1);            // mark NAS as "configured"

No PAM call. No challenge-response. No nonce. No rate limit. The only check is sub_7DE0() – “is this NAS already configured?”

sub_7DE0 checks two things: does /etc/synoinfo.conf contain configured=yes, and is the admin password non-empty?

// sub_7DE0 -- configuration guard
if ( dword_14880 )
    return 1;  // cached: already configured
dword_14880 = SLIBCFileCheckKeyValue("/etc/synoinfo.conf", "configured", "yes", 1);
if ( dword_14880 )
    return 1;
v1 = sub_7430();  // check if admin password is empty
if ( !v1 )
{
    // password is NOT empty -> mark as configured
    dword_14880 = 1;
    SLIBCFileSetKeyValue("/etc/synoinfo.conf", "configured", "yes", ...);
    return 1;
}
return 0;  // unconfigured

And the password check (sub_7430) calls crypt("", hash) to test whether the admin shadow entry is empty:

// sub_7430 -- returns true if admin password is empty
getspnam_r("admin", &result_buf, buffer, 0x400, &result);
v0 = crypt("", result_buf.sp_pwdp);
return strcmp(result_buf.sp_pwdp, v0) == 0;

If both conditions are false – no configured=yes in the config file, and the admin password is empty – the guard returns 0, and sub_52F0 sets the admin password to whatever the attacker sent:

// sub_52F0 -- applies QuickConf settings (runs as root)
v7 = (char *)a1[1];  // password from packet
SLIBUserShadowPasswordSet(v17, v7);
SYNOLocalAccountUserSetOne(...);

This is a root daemon calling SLIBUserShadowPasswordSet. The attacker’s password is written directly to /etc/shadow.

But You Need Encryption

There is one wrinkle that static analysis alone almost misses. Back in the dispatch function, there is this line:

if ( FHOSTPacketReadPlain(...) <= 0 || !dword_12570 && v14 != 1 )
    return -1;

dword_12570 defaults to 0. It is only set to 1 by the -c command-line flag. So in production, plaintext packets are restricted to type 1 (info requests). A type 12 packet sent in plaintext gets silently dropped.

This means the attacker must use the encrypted protocol. Fortunately, the encryption is crypto_box_seal (libsodium sealed box) with a Curve25519 key exchange that is itself unauthenticated:

  1. Send a plaintext type 1 info query with your Curve25519 public key (field 0xC4)
  2. The NAS responds with an encrypted packet containing its own public key
  3. Decrypt with your secret key, extract the server’s public key
  4. Encrypt the type 12 QuickConf payload with crypto_box_seal(payload, server_pubkey)
  5. Send it

Two packets. No authentication at any step. The key exchange is anonymous – the NAS accepts any public key from any source.

Validation

I validated this against a live DS218+ running DSM 7.2.2 build 72803. The NAS is configured (admin password set), so the guard correctly rejects QuickConf. But the protocol works end to end:

Test 1 -- Encrypted info query:
  Sent: 92-byte plaintext type 1 with Curve25519 pubkey
  Received: 2 encrypted responses (432 bytes each)
  Decrypted: 43 fields including hostname, model, serial, server pubkey
  Result: PASS

Test 2 -- Encrypted QuickConf (type 12):
  Sent: 140-byte encrypted packet
  Received: nothing (5-second timeout)
  Result: PASS (silently rejected -- guard returned 1)

The guard works. On a configured NAS, QuickConf does nothing. On an unconfigured NAS – factory reset, first boot, or admin password cleared – the guard returns 0 and the attacker’s password is applied.

The fix is straightforward: require a physical confirmation step (button press, LCD code, QR scan) before accepting QuickConf. The encrypted channel is already there – it just needs a trust anchor that is not “anyone on the LAN.”

Unauthenticated Information Disclosure: 46 Fields From 20 Bytes

The info handler (type 1) responds to any packet with the correct 8-byte magic header and a type field. A 20-byte UDP packet – 8 bytes of magic plus two TLV fields – returns the full NAS identity:

Field Value
Hostname xxxxxx
Model DS218+
DSM version 7.2.2
Build number 72803
Serial number (full) 1990xxxxxxxxx
Serial number (obfuscated) 99PCxxxxxx
Platform ID synology_apollolake_218+
HTTP / HTTPS ports 5000 / 5001
Admin password set 1 (yes)
Configuration status 1 (configured)
MAC address 00:11:32:bb:be:67
DNS server 192.168.0.1

That is 46 fields from a single unauthenticated UDP packet. The serial number enables warranty fraud and social engineering with Synology support. The exact build number maps to a specific firmware image, enabling targeted exploit selection. The configuration status tells an attacker whether the QuickConf attack is viable. The platform ID maps directly to a firmware download URL on Synology’s CDN – an attacker can download the same firmware for offline analysis.

If the attacker includes a Curve25519 public key in the request, the response is encrypted and also includes the server’s public key – which is all that is needed for the QuickConf attack.

No rate limiting. No logging. No way to disable it without stopping findhostd entirely.

Signal Handler Calls malloc

The SIGUSR1 handler (sub_6BC0) is used to trigger a refresh of the NAS network configuration and broadcast an updated info response. The handler calls:

All of these are async-signal-unsafe. If SIGUSR1 arrives while the main thread is inside malloc or free, the allocator’s internal data structures are inconsistent. The handler calls malloc again, corrupts the heap, and the daemon crashes – or worse, the corruption is exploitable.

Sending SIGUSR1 to findhostd requires local access (the daemon runs as root, so you need either root or the same UID). But a local attacker who can time the signal against a heap operation has a path to code execution in a root process.

Passwords Are Obfuscated, Not Encrypted

Network settings packets (type 3) carry the admin password for PAM authentication. The password is “protected” using MatrixDecode – a floating-point matrix multiplication encoding built into libfindhost.so.1. The matrix is hardcoded in the binary. Any attacker who captures a type 3 packet on the wire – passive sniffing on a LAN, ARP spoofing, a mirror port – can recover the plaintext password by extracting the matrix from the library and reversing the multiplication.

The irony is that crypto_box_seal is already used for info responses. The encryption infrastructure exists in the binary. Password-carrying packets just do not use it.

Root Forever

findhostd starts as root and never drops privileges. The daemon needs root for exactly three operations: binding privileged sockets (though 9999 is unprivileged), writing /etc/synoinfo.conf, and reading /etc/shadow for PAM auth. After setup, the daemon sits in a select() loop parsing untrusted UDP packets as root.

This means any code execution vulnerability in the packet parser – buffer overflow, heap corruption via signal handler, format string – yields immediate root access. The daemon should drop to a dedicated service user after binding sockets and opening the PAM handle, or use capabilities (CAP_NET_BIND_SERVICE, CAP_DAC_READ_SEARCH) instead of full root.

The Rest

Four medium-severity findings round out the logic-level issues:

MAC address parsing with unbounded loop (CWE-120). The MAC-to-string conversion in the response builder uses sprintf in a loop driven by a length byte from the parsed packet. A crafted length greater than 20 could overflow the destination stack buffer, though libfindhost.so.1 caps the MAC field at 20 bytes during parsing, which limits practical exploitation.

Unauthenticated key cache poisoning (CWE-345). The info handler accepts any Curve25519 public key and caches it without verification. An attacker can poison the key cache, potentially causing the NAS to encrypt responses with the attacker’s key instead of the legitimate client’s.

Hostname matching bypass (CWE-290). Network reconfiguration packets are filtered by hostname – the packet’s hostname must match the NAS’s hostname. But the hostname is disclosed unauthenticated in every info response. The filter is security theater.

Protocol version downgrade (CWE-757). Sending a version field with value <= 0x1C97 causes the PAM authentication path to ignore the username field in the packet and use the hardcoded string "admin". A downgrade forces authentication against the admin account regardless of which username the client specified.

Three lows: predictable anti-collision timer (srand(time(0))), recursive /proc walk without depth limit during signal handling, and SO_KEEPALIVE set on UDP sockets (harmless but suggesting TCP code reuse).

Fuzzing

To check whether the parser had memory corruption bugs beyond what static analysis found, AFL++ fuzzing campaigns were run across five harnesses:

Building the harnesses required creating stub implementations of 91 Synology-internal symbols across 14 .so files – libsynosdk, libsynonetsdk, libsynosystemsdk, and others – so the real binaries could run outside the Synology environment. The stubs return harmless defaults; the interesting code paths are all in libfindhost.so.1 and findhostd itself.

15.4 million total executions across all campaigns. Zero crashes. Zero hangs.

The parser is well-bounded. Every field in the 79-entry grgfieldAttribs descriptor table has a maximum length, and the parser enforces it. The risks in findhostd are logic-level – missing authentication, information leakage, design-level protocol weaknesses – not memory safety.

The Numbers

Across all four binaries in the FindHost attack surface:

Binary Functions Vulnerabilities
synosearchagent 49 10 (1C, 2H, 4M, 3L)
libfindhost.so.1 81 9 (1C, 3H, 3M, 2L)
findhostd 59 12 (1C, 4H, 4M, 3L)
SYNO.Core.Findhost.so 18 9 (1C, 3H, 2M, 3L)
Total 207 40

Two findings are validated with PoC scripts that run against a live NAS:

Script Vulnerability Result
validate_quickconf.py Pre-auth admin takeover (CWE-306) Confirmed: guard rejects on configured NAS, proving it is the sole protection
validate_infodisclosure.py Unauthenticated info disclosure (CWE-200) Confirmed: 46 fields from a single 20-byte UDP packet

The Process

The entire research – from “what version is my NAS running?” to validated PoC scripts – was driven conversationally. Each prompt was a natural follow-up to whatever the previous step revealed:

  1. “My NAS is at this IP, which version am I running?” – fingerprinted via info.cgi
  2. “Is this the latest version?” – checked Synology’s update RSS feed
  3. “Download the firmware” – pulled .pat from CDN, unpacked via Docker tool
  4. “What serves the unauthenticated info.cgi endpoint?” – traced symlink to synosearchagent
  5. “Decompile it” – IDA Pro 9.3 headless, 49 functions
  6. “Find vulnerabilities” – 10 findings, identified libfindhost.so as the real attack surface
  7. “Decompile and audit libfindhost” – 81 functions, 9 findings, pointed to findhostd
  8. “Fuzz the parser” – AFL++ campaigns, 15.4M executions, 0 crashes
  9. “Decompile findhostd and the WebAPI module” – 77 more functions, 21 more findings
  10. “Validate the critical finding” – live Curve25519 key exchange, encrypted QuickConf test
  11. “Create the PoCs” – working Python scripts that demonstrate both validated vulnerabilities

No step was planned from the start. The model followed the code, I followed the model’s findings, and the interesting threads pulled us deeper. The critical QuickConf vulnerability was not something I went looking for – it fell out of reading the decompiled packet type dispatch table and noticing that one handler had no authentication call.

That is the part that surprised me: the model did not just find the missing auth check in isolation. It traced the complete attack chain across multiple binaries – the key exchange in libfindhost.so.1, the dispatch logic in findhostd, the configuration guard in sub_7DE0, the password setter in sub_52F0 – and then built a working PoC that validated the entire chain against live hardware. The encryption requirement (QuickConf must use crypto_box_seal, not plaintext) was only discovered during live validation, not from static analysis alone.

#Synology #Security #Reverse-Engineering #Ida-Pro #Firmware