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:
- Send a plaintext type 1 info query with your Curve25519 public key (field 0xC4)
- The NAS responds with an encrypted packet containing its own public key
- Decrypt with your secret key, extract the server’s public key
- Encrypt the type 12 QuickConf payload with
crypto_box_seal(payload, server_pubkey) - 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:
SLIBCNetGetInterfaceInfo()– allocates memorySLIBCSzListAlloc()– callsmalloc()FHOSTPacketWrite()– complex serializationSYNONetSendUDPBroadCast()– network I/O
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:
- Standalone reimplementation of
FHOSTPacketRead(instrumented withafl-clang-fast, 41 PCGUARD locations) dlopen-based harness against the reallibfindhost.so.1(QEMU mode, 92 edges)findhostdpacket flow harness:PacketRead->PacketWrite(QEMU, 113 edges)- Encrypted path harness with real
libsodium(QEMU, 73 edges) - Multi-entry harness covering all three parse functions
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:
- “My NAS is at this IP, which version am I running?” – fingerprinted via
info.cgi - “Is this the latest version?” – checked Synology’s update RSS feed
- “Download the firmware” – pulled
.patfrom CDN, unpacked via Docker tool - “What serves the unauthenticated info.cgi endpoint?” – traced symlink to
synosearchagent - “Decompile it” – IDA Pro 9.3 headless, 49 functions
- “Find vulnerabilities” – 10 findings, identified
libfindhost.soas the real attack surface - “Decompile and audit libfindhost” – 81 functions, 9 findings, pointed to
findhostd - “Fuzz the parser” – AFL++ campaigns, 15.4M executions, 0 crashes
- “Decompile findhostd and the WebAPI module” – 77 more functions, 21 more findings
- “Validate the critical finding” – live Curve25519 key exchange, encrypted QuickConf test
- “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.