[PWN2OWN IRELAND 2025] Bypassing Authentication via Synology DS925+ SAML SSO
I. Story
Getting into Pwn2Own that time was a bit of a lucky break for me: I found the bug and bypassed the requirement right before the registration deadline. When I first started hunting, I didn’t know anything about pwn, so I collabed with my friend (@ngocquy0307) to help me out during the process, reversing the parts of the code I couldn’t understand, and making sure we didn’t miss any pwn bugs. As for me, I decided to focus on logic vulnerabilities.
Not long before the competition, I happened to find an SSO bypass bug in a small Microsoft plugin. For some reason I had this gut feeling — like my ancestors were guiding me — so when I started auditing, I decided to make SSO my first target. And luckily enough, the app’s SAML SSO really did have a bug.
We didn’t use any AI throughout this whole bug-hunting process.
II. Analyst
SYNOPAMSSO::samlAuth Workflow
The SYNOPAMSSO::samlAuth function handles the logic when a user logs in via SAML SSO.
int samlAuth(const std::string& samlResponseB64, std::string& outUser)
{
SYNO::SSO::SSOSAMLClient client; // [1]
std::string resp = samlResponseB64;
for (char& c : resp)
if (c == ' ')
c = '+';
client.VerifySamlResponse(resp); // [2]
const std::string& samlUser = client.m_nameId;
char realName[1024] = {0};
if (SLIBUserRealNameGet(samlUser.c_str(), realName, sizeof realName) < 0) {
syslog(LOG_ERR, "%s:%d no such user: %s",
"saml_auth.cpp", 30, samlUser.c_str());
return 2;
}
if (strcasecmp(samlUser.c_str(), realName) != 0) {
syslog(LOG_ERR, "%s:%d username not match: %s, %s",
"saml_auth.cpp", 34, samlUser.c_str(), realName);
return 2;
}
bool qualified = std::strchr(samlUser.c_str(), '\\')
|| std::strchr(samlUser.c_str(), '@');
if (!qualified && !client.m_allowLocalUser) { // [3]
syslog(LOG_ERR, "%s:%d user is local: %s, %s",
"saml_auth.cpp", 40, samlUser.c_str(), realName);
return 2;
}
outUser = samlUser;
return 0;
}
__int64 __fastcall SYNOPAMSSO::samlAuth(std::string *samlRes, std::string *outUser)
{
// [...]
std::string nameID; // [rsp+40h] [rbp-600h] BYREF
_BYTE v28[248]; // [rsp+60h] [rbp-5E0h] BYREF
__int64 v29; // [rsp+158h] [rbp-4E8h]
__int64 v30; // [rsp+160h] [rbp-4E0h]
// [...]
SYNO::SSO::SSOSAMLClient::SSOSAMLClient((SYNO::SSO::SSOSAMLClient *)v28); // [1]
data = samlRes->data;
size = samlRes->size;
v24 = (std::string *)v26;
memset(v32, 0, sizeof(v32));
string::string(&v24, data, &data[size]); // basic_string<char>::_M_construct
v4 = v24;
for ( i = (std::string *)((char *)v24 + v25); i != v4; v4 = (std::string *)((char *)v4 + 1) )
{
if ( LOBYTE(v4->data) == 32 )
LOBYTE(v4->data) = 43;
}
SYNO::SSO::SSOSAMLClient::VerifySamlResponse(v28, &v24, 0.0); // [2]
nameID.data = nameID.local_str;
string::string(&nameID, v29, v29 + v30);
v6 = SLIBUserRealNameGet(nameID.data, v32, 1024LL);
if ( nameID.data != nameID.local_str )
operator delete(nameID.data, *(_QWORD *)nameID.local_str + 1LL);
nameID.data = nameID.local_str;
v7 = v29 + v30;
if ( v6 < 0 )
{
string::string(&nameID, v29, v7);
__syslog_chk(3LL, 2LL, "%s:%d no such user: %s", "saml_auth.cpp", 30, nameID.data);
goto LABEL_34;
}
string::string(&nameID, v29, v7);
v8 = (std::string *)nameID.data;
v9 = strcasecmp(nameID.data, (const char *)v32);
if ( v8 != (std::string *)nameID.local_str )
operator delete(v8, *(_QWORD *)nameID.local_str + 1LL);
nameID.data = nameID.local_str;
v10 = v29 + v30;
if ( v9 )
{
string::string(&nameID, v29, v10);
__syslog_chk(3LL, 2LL, "%s:%d username not match: %s, %s", "saml_auth.cpp", 34, nameID.data, (const char *)v32);
goto LABEL_34;
}
string::string(&nameID, v29, v10);
v11 = nameID.data;
if ( nameID.data )
{
s = nameID.data;
v11 = nameID.data;
v12 = strchr(nameID.data, 92);
v13 = 2LL;
if ( !v12 )
{
v11 = (char *)s;
v13 = strchr(s, 64) == 0LL ? 1 : 8;
}
if ( v11 == nameID.local_str )
goto LABEL_15;
}
else
{
LODWORD(v13) = 0;
}
sa = v13;
operator delete(v11, *(_QWORD *)nameID.local_str + 1LL);
v13 = sa;
LABEL_15:
nameID.data = nameID.local_str;
if ( v31 || (v14 = 260LL, _bittest64(&v14, v13)) )
{
string::string(&nameID, v29, v29 + v30);
local_str = (std::string *)outUser->data;
if ( nameID.data == nameID.local_str )
{
v19 = nameID.size;
if ( nameID.size )
{
if ( nameID.size == 1 )
LOBYTE(local_str->data) = nameID.local_str[0];
else
memcpy(local_str, nameID.local_str, nameID.size);
v19 = nameID.size;
local_str = (std::string *)outUser->data;
}
outUser->size = v19;
*((_BYTE *)&local_str->data + v19) = 0;
local_str = (std::string *)nameID.data;
goto LABEL_21;
}
if ( local_str == (std::string *)outUser->local_str )
{
v20 = _mm_loadu_si128((const __m128i *)&nameID.size);
outUser->data = nameID.data;
*(__m128i *)&outUser->size = v20;
}
else
{
v16 = _mm_loadu_si128((const __m128i *)&nameID.size);
v17 = *(_QWORD *)outUser->local_str;
outUser->data = nameID.data;
*(__m128i *)&outUser->size = v16;
if ( local_str )
{
nameID.data = (char *)local_str;
*(_QWORD *)nameID.local_str = v17;
goto LABEL_21;
}
}
nameID.data = nameID.local_str;
local_str = (std::string *)nameID.local_str;
LABEL_21:
nameID.size = 0LL;
LOBYTE(local_str->data) = 0;
if ( nameID.data != nameID.local_str )
operator delete(nameID.data, *(_QWORD *)nameID.local_str + 1LL);
goto LABEL_23;
}
string::string(&nameID, v29, v29 + v30);
__syslog_chk(3LL, 2LL, aSDUserIsLocalS_0, "saml_auth.cpp", 40LL, nameID.data, v32);
LABEL_34:
if ( nameID.data != nameID.local_str )
operator delete(nameID.data, *(_QWORD *)nameID.local_str + 1LL);
v9 = 2;
LABEL_23:
if ( v24 != (std::string *)v26 )
operator delete(v24, v26[0] + 1LL);
SYNO::SSO::SSOSAMLClient::~SSOSAMLClient((SYNO::SSO::SSOSAMLClient *)v28);
return v9;
}
The function performs two main tasks: first, it initializes the SAML client [1], and then uses it to verify the SAML response [2]. The entire SAML validation logic takes place inside verifySamlResponse.
[1] SSOSAMLClient Constructor
SSOSAMLClient(): (constructor — loads SAML config into the client)
Fields (all initialized to empty/false by default):
idpIssuerUrl, acsUrl, idpEntityId, nameidFormat, name,
verifyMode, configPath, certPath, matchLocalUser
1. Read /usr/syno/etc/ssoclient/saml.conf into a JsonValue
└─ read fails → return early, leave all fields at defaults
2. Store the parsed JSON in the base JsonValue (so callers can see the raw config)
3. Pull individual fields from the config (each falls back to a default if missing):
• idpIssuerUrl ← config["saml_idp_issuer_url"] or ""
• acsUrl ← config["saml_acs_url"] or ""
• idpEntityId ← config["saml_idp_entity_id"] or ""
• name ← config["saml_name"] or ""
• matchLocalUser ← config["saml_match_local_user"] or false
• verifyMode ← config["saml_response_signature"] or "response"
4. Override with hardcoded constants (config values for these are ignored):
• nameidFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
• configPath = "/usr/syno/etc/ssoclient/saml.conf"
• certPath = "/usr/syno/etc/ssoclient/saml_cert.ca"
class SSOSAMLClient : public Json::Value {
public:
SSOSAMLClient();
bool readSAMLConfig(Json::Value& out);
std::string idpIssuerUrl;
std::string acsUrl;
std::string idpEntityId;
std::string nameidFormat;
std::string name;
std::string mNameId;
std::string verifyMode;
std::string configPath;
std::string certPath;
bool matchLocalUser;
};
SSOSAMLClient::SSOSAMLClient()
: Json::Value(Json::nullValue)
, matchLocalUser(false)
{
Json::Value config(Json::nullValue);
if (!readSAMLConfig(config))
return;
*static_cast<Json::Value*>(this) = config;
idpIssuerUrl = JsGetStr(config, "saml_idp_signin_url");
acsUrl = JsGetStr(config, "saml_acs_url");
idpEntityId = JsGetStr(config, "saml_idp_entity_id");
name = JsGetStr(config, "saml_name");
matchLocalUser = JsGetBool(config, "saml_allow_local_user", false);
verifyMode = JsGetStr(config, "saml_response_signature");
nameidFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified";
configPath = "/usr/syno/etc/ssoclient/saml.conf";
certPath = "/usr/syno/etc/ssoclient/saml_cert.ca";
}
bool SSOSAMLClient::readSAMLConfig(Json::Value& out)
{
static const char* kConfPath = "/usr/syno/etc/ssoclient/saml.conf";
RunAs guard(0, 0, "sso_saml.cpp", 494);
if (SLIBCFileExist(kConfPath)) {
if (SLIBCFileExist(kConfPath) == 1) {
if (!out.fromFile(kConfPath)) {
syslog(LOG_ERR, "%s:%d failed to load json file", "sso_saml.cpp", 506);
throw std::runtime_error("failed to load json file");
}
}
if (!out.isMember("saml_response_signature"))
out["saml_response_signature"] = "response";
return true;
}
out["sso_saml_enable"] = "";
out["saml_name"] = "SAML";
out["saml_idp_entity_id"] = "";
out["saml_idp_signin_url"] = "";
out["saml_acs_url"] = "";
out["saml_idp_certificate"] = "";
out["saml_valid_date"] = "";
out["saml_idp_certificate_expired"] = "";
return false;
}
[2] verifySamlResponse
verifySamlResponse(samlResponseB64):
1. Initialize OpenSAML, then Xerces
└─ init fail → throw "SAMLConfig::getConfig().init failed"
2. Base64-decode the payload
└─ decode fail → throw "decoded Fail"
3. Parse XML into a DOM, then build the typed XMLObject tree
└─ doc null → throw "doc is null"
└─ object null → throw "source is null"
4. If SSOSAMLClient.verifyMode ∈ {response, both}: verify the <Response> signature
└─ not a SignableObject → throw "signable is null"
└─ no <Signature> → throw "getSignature is null"
└─ signature invalid → throw (inside SAML_verifySignature, e.g.
"resolve fail" / "certificate can't be verified")
5. Message must be a samlp:Response
└─ not a Response → throw "message was not a samlp:Response"
6. Status must be Success
└─ Status null, OR StatusCode null, OR value ≠ ...:status:Success
→ throw "attribute authority returned a SAML error"
7. Schema-validate the Response
8. Response Issuer must match idpEntityId
└─ Issuer missing → throw "issuer is null"
└─ does not match → throw "issuer does not match: <value>"
└─ matches → log(debug) "issuer: <idpEntityId>:<value>"
9. Must contain at least one <Assertion>
└─ empty → throw "resolve is empty"
10. Iterate the Assertions in order and take the FIRST one that passes
every check below:
a. If SSOSAMLClient.verifyMode ∈ {assertion, both}: verify the Assertion's signature.
└─ on failure SAML_verifySignature THROWS (aborts the whole call;
this is NOT a skip — later assertions are never reached).
b. Skip this assertion (log only, no throw → continue to the next) if:
- Subject missing → log "subject is null"
- Issuer missing → log "assert issuer is null"
- Issuer ≠ idpEntityId → log "issuer does not match: <value>"
- <Conditions> present AND now < NotBefore → log "Assertion is not yet valid."
- <Conditions> present AND now ≥ NotOnOrAfter → log "Assertion is no longer valid."
(If the Assertion has no <Conditions> element, the validity window
is NOT enforced.)
c. Otherwise every check passed:
- nameId = trimmed text of Subject's NameID
- STOP — the first matching assertion wins; remaining assertions
are ignored (the binary breaks out of the loop here).
(NameID is read unconditionally and is NOT null-checked: a missing
NameID is not a skip condition.)
11. nameId must not be empty
└─ empty → throw "nameId is empty" (no assertion passed every check)
12. Teardown: delete the object tree, destroy the input sources,
then SAMLConfig.term()
long SSOSAMLClient::VerifySamlResponse(const std::string& samlResponseB64)
{
// --- 1. Initialize OpenSAML, then Xerces. -------------------------------
opensaml::SAMLConfig& saml = opensaml::SAMLConfig::getConfig();
if (!saml.init(/*initXMLTooling=*/true))
throw std::runtime_error("SAMLConfig::getConfig().init failed");
xercesc::XMLPlatformUtils::Initialize();
// --- 2. Base64-decode the payload. --------------------------------------
XMLSize_t decodedLen = 0;
XMLByte* decoded = xercesc::Base64::decode(
reinterpret_cast<const XMLByte*>(samlResponseB64.data()), &decodedLen);
if (!decoded)
throw std::runtime_error("decoded Fail");
// --- 3. Parse the XML into a DOM, then build a typed XMLObject tree. -----
xercesc::MemBufInputSource membuf(decoded, decodedLen, "SAMLResponse", true);
xercesc::Wrapper4InputSource source(&membuf, false);
xercesc::DOMDocument* doc =
xmltooling::XMLToolingConfig::getConfig().getParser().parse(source);
if (!doc)
throw std::runtime_error("doc is null");
xmltooling::XMLObject* object =
xmltooling::XMLObjectBuilder::buildOneFromElement(
doc->getDocumentElement(), true);
if (!object)
throw std::runtime_error("source is null");
// --- 4. If verifyMode is "response" or "both", verify the <Response>
// signature. -------------------------------------------------------
if (this->verifyMode == "response" || this->verifyMode == "both") {
auto* signable = dynamic_cast<xmltooling::SignableObject*>(object);
if (!signable)
throw std::runtime_error("signable is null");
if (!signable->getSignature())
throw std::runtime_error("getSignature is null");
verifySignature(signable->getSignature());
}
// --- 5. The message must be a samlp:Response. ---------------------------
auto* response = dynamic_cast<opensaml::saml2p::Response*>(object);
if (!response)
throw std::runtime_error("message was not a samlp:Response");
// --- 6. The SAML status must be "Success". A null Status, a null
// StatusCode, or any value other than SUCCESS raise the same error.
opensaml::saml2p::Status* status = response->getStatus();
if (!status
|| !status->getStatusCode()
|| !xercesc::XMLString::equals(status->getStatusCode()->getValue(),
opensaml::saml2p::StatusCode::SUCCESS))
throw std::runtime_error("attribute authority returned a SAML error");
// --- 7. Schema-validate the Response. -----------------------------------
xmltooling::SchemaValidators.validate(response);
// --- 8. The Response Issuer must match the configured IdP entity id. -----
if (!response->getIssuer())
throw std::runtime_error("issuer is null");
char* issuerUtf8 = xercesc::XMLString::transcode(
response->getIssuer()->getTextContent());
if (!issuerUtf8)
throw std::logic_error("basic_string: construction from null is not valid");
xercesc::XMLString::trim(issuerUtf8);
std::string issuer(issuerUtf8);
xercesc::XMLString::release(&issuerUtf8);
if (issuer != this->idpEntityId)
throw std::runtime_error("issuer does not match: " + issuer);
syslog(LOG_DEBUG, "%s:%d issuer: %s:%s", "sso_saml.cpp", 377,
idpEntityId.c_str(), issuer.c_str());
// --- 9. There must be at least one <Assertion>. -------------------------
const std::vector<opensaml::saml2::Assertion*>& assertions =
response->getAssertions();
if (assertions.empty())
throw std::runtime_error("resolve is empty");
// --- 10. Take the NameID from the FIRST assertion that passes EVERY check.
// A failing check is logged (no throw) and we move to the next
// assertion. ---------------
for (opensaml::saml2::Assertion* assertion : assertions) {
// 10a. Optional per-assertion signature check.
if (this->verifyMode == "assertion" || this->verifyMode == "both")
verifySignature(assertion->getSignature());
// 10b. Subject must be present.
opensaml::saml2::Subject* subject = assertion->getSubject();
if (!subject) {
syslog(LOG_ERR, "%s:%d subject is null", "sso_saml.cpp", 394);
continue;
}
// 10c. Issuer must be present.
if (!assertion->getIssuer()) {
syslog(LOG_ERR, "%s:%d assert issuer is null", "sso_saml.cpp", 399);
continue;
}
// 10d. Assertion Issuer text must match the configured IdP entity id.
// (inlined getTextContent + transcode + trim -> std::string)
char* aIssuerUtf8 = xercesc::XMLString::transcode(
assertion->getIssuer()->getTextContent());
if (!aIssuerUtf8)
throw std::logic_error("basic_string: construction from null is not valid");
xercesc::XMLString::trim(aIssuerUtf8);
std::string assertionIssuer(aIssuerUtf8);
xercesc::XMLString::release(&aIssuerUtf8);
if (assertionIssuer != this->idpEntityId) {
syslog(LOG_ERR, "%s:%d issuer does not match: %s", "sso_saml.cpp",
406, assertionIssuer.c_str());
continue;
}
// 10e. If <Conditions> are present, enforce the validity window.
if (opensaml::saml2::Conditions* cond = assertion->getConditions()) {
time_t now = time(nullptr);
if (now < cond->getNotBefore()) {
syslog(LOG_ERR, "%s:%d Assertion is not yet valid.",
"sso_saml.cpp", 413);
continue;
}
if (now >= cond->getNotOnOrAfter()) {
syslog(LOG_ERR, "%s:%d Assertion is no longer valid.",
"sso_saml.cpp", 418);
continue;
}
}
// 10f. All checks passed: this assertion's NameID is the user. Stop.
// (inlined getTextContent + transcode + trim -> std::string)
char* nameIdUtf8 = xercesc::XMLString::transcode(
subject->getNameID()->getTextContent());
if (!nameIdUtf8)
throw std::logic_error("basic_string: construction from null is not valid");
xercesc::XMLString::trim(nameIdUtf8);
m_nameId = nameIdUtf8;
xercesc::XMLString::release(&nameIdUtf8);
break;
}
// --- 11. A user must have been extracted. -------------------------------
if (m_nameId.empty())
throw std::runtime_error("nameId is empty");
// --- 12. Teardown. ------------------------------------------------------
delete object;
return opensaml::SAMLConfig::getConfig().term(true);
}
__int64 __fastcall SYNO::SSO::SSOSAMLClient::VerifySamlResponse(__int64 a1, _QWORD *a2)
{
// [...]
Config = opensaml::SAMLConfig::getConfig((opensaml::SAMLConfig *)a1);
if ( !(*(unsigned __int8 (__fastcall **)(__int64, __int64))(*(_QWORD *)Config + 16LL))(Config, 1LL) )
{
// [...]
}
xercesc_3_2::XMLPlatformUtils::Initialize(off_61C08, 0LL, 0LL, 0LL);
v14 = xercesc_3_2::Base64::decode(*a2, &v91, 0LL, 0LL);
if ( !v14 )
{
// [...]
}
v15 = (char **)xercesc_3_2::XMLPlatformUtils::fgMemoryManager; // xercesc_3_2::XMLPlatformUtils::fgMemoryManager non set -> null
xercesc_3_2::MemBufInputSource::MemBufInputSource(v99, v14, v91, "SAMLResponse", 1LL, *(_QWORD *)xercesc_3_2::XMLPlatformUtils::fgMemoryManager);
xercesc_3_2::Wrapper4InputSource::Wrapper4InputSource(v98, v99, 0LL, *v15);
v16 = xmltooling::XMLToolingConfig::getConfig((xmltooling::XMLToolingConfig *)v98);
v17 = (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)v16 + 64LL))(v16); // xmltooling::XMLToolingInternalConfig::getParser() const
v18 = xmltooling::ParserPool::parse(v17, v98);
if ( !v18 )
{
// [...]
}
v92 = v18;
v19 = (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)v18 + 104LL))(v18); // non-virtual thunk to xercesc_3_2::DOMDocumentImpl::getDocumentElement() const
lpsrc = (void *)xmltooling::XMLObjectBuilder::buildOneFromElement(v19, 1LL);
v93 = lpsrc;
v92 = 0LL;
if ( !lpsrc )
{
// [...]
}
v89 = a1 + 280;
if ( !(unsigned int)std::string::compare(a1 + 280, "response") || !(unsigned int)std::string::compare(v89, "both") ) // check verifyMode
{
v20 = lpstype;
v21 = __dynamic_cast(lpsrc, lpstype, off_61D98, -1LL);
v22 = v21;
// [...]
v23 = (*(__int64 (__fastcall **)(void *))(*(_QWORD *)v22 + 240LL))(v22);
sub_3CFF0(v23); // verify signature
}
else
{
v20 = lpstype;
}
v24 = (char *)__dynamic_cast(lpsrc, v20, off_61E90, -1LL);
v25 = v24;
if ( !v24 )
{
// [...]
}
if ( !(*(__int64 (__fastcall **)(char *))(*(_QWORD *)&v24[*(_QWORD *)(*(_QWORD *)v24 - 472LL)] + 416LL))(&v24[*(_QWORD *)(*(_QWORD *)v24 - 472LL)]) ) // virtual thunk to opensaml::saml2p::StatusResponseTypeImpl::getStatus() const
goto LABEL_94;
v26 = (*(__int64 (__fastcall **)(char *))(*(_QWORD *)&v25[*(_QWORD *)(*(_QWORD *)v25 - 472LL)] + 416LL))(&v25[*(_QWORD *)(*(_QWORD *)v25 - 472LL)]); // virtual thunk to opensaml::saml2p::StatusResponseTypeImpl::getStatus() const
if ( !(*(__int64 (__fastcall **)(__int64))(*(_QWORD *)v26 + 280LL))(v26) ) // virtual thunk to opensaml::saml2p::StatusImpl::getStatusCode() const
goto LABEL_94;
v27 = (*(__int64 (__fastcall **)(char *))(*(_QWORD *)&v25[*(_QWORD *)(*(_QWORD *)v25 - 472LL)] + 416LL))(&v25[*(_QWORD *)(*(_QWORD *)v25 - 472LL)]); // virtual thunk to opensaml::saml2p::StatusResponseTypeImpl::getStatus() const
v28 = (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)v27 + 280LL))(v27); // virtual thunk to opensaml::saml2p::StatusImpl::getStatusCode() const
v29 = (__int16 *)(*(__int64 (__fastcall **)(__int64))(*(_QWORD *)v28 + 248LL))(v28); // virtual thunk to opensaml::saml2p::StatusCodeImpl::getValue() const
// [...]
xmltooling::ValidatorSuite::validate(off_61BF8, &v25[*(_QWORD *)(*(_QWORD *)v25 - 256LL)]);
if ( !(*(__int64 (__fastcall **)(char *))(*(_QWORD *)&v25[*(_QWORD *)(*(_QWORD *)v25 - 328LL)] + 288LL))(&v25[*(_QWORD *)(*(_QWORD *)v25 - 328LL)]) ) // virtual thunk to opensaml::saml2p::StatusResponseTypeImpl::getIssuer() const
{
// [...] error
}
v33 = (_QWORD *)(*(__int64 (__fastcall **)(char *))(*(_QWORD *)&v25[*(_QWORD *)(*(_QWORD *)v25 - 328LL)] + 288LL))(&v25[*(_QWORD *)(*(_QWORD *)v25 - 328LL)]); // virtual thunk to opensaml::saml2p::StatusResponseTypeImpl::getIssuer() const
v34 = (char *)v33
+ *(_QWORD *)(*v33 - 336LL)
+ *(_QWORD *)(*(_QWORD *)((char *)v33 + *(_QWORD *)(*v33 - 336LL)) - 256LL);
issue_content = (*(__int64 (__fastcall **)(char *, _QWORD))(*(_QWORD *)v34 + 152LL))(v34, 0LL); // virtual thunk to xmltooling::AbstractSimpleElement::getTextContent(unsigned int) const
v36 = *v15;
_issue_content = (char *)xercesc_3_2::XMLString::transcode(v35, *v15);
s = _issue_content;
if ( !_issue_content )
{
s2 = v102;
LABEL_102:
std::__throw_logic_error("basic_string: construction from null is not valid");
}
xercesc_3_2::XMLString::trim((xercesc_3_2::XMLString *)_issue_content, v36); // trim(issuer, "")
v38 = s;
// std::string s2 = string::string(_issue_content = s, strlen(_issue_content))
v42 = s2.data;
v43 = (const char *)a1[15]; // get size issuer config
v44 = a1[16]; // get issuer from config
if ( v44 != s2.size // check size
|| v44 && (v88 = (const void *)a1[15], v45 = memcmp(v88, s2.data, v44), v43 = (const char *)v88, v45) ) // compare issuer config với samlres
{
// [...]
}
v46 = (*(__int64 (__fastcall **)(char *, __int64))(*(_QWORD *)v25 + 448LL))(v25, 2LL);
v47 = *(_QWORD **)v46;
v86 = *(_QWORD **)(v46 + 8);
if ( *(_QWORD **)v46 == v86 )
{
// [...]
}
while ( 1 )
{
if ( !(unsigned int)std::string::compare(v89, "assertion") || !(unsigned int)std::string::compare(v89, "both") )
{
v48 = *(_QWORD *)(*(_QWORD *)*v47 - 304LL) + *v47;
v49 = (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)v48 + 240LL))(v48);
sub_3CFF0(v49); // verify signature
}
// same flow
v50 = (*(__int64 (__fastcall **)(_QWORD))(*(_QWORD *)*v47 + 352LL))(*v47);
if ( !v50 )
{
__syslog_chk(3LL, 2LL, "%s:%d subject is null", "sso_saml.cpp", 394);
goto LABEL_44;
}
v51 = *(_QWORD *)(*(_QWORD *)*v47 - 328LL) + *v47;
v52 = (_QWORD *)(*(__int64 (__fastcall **)(__int64))(*(_QWORD *)v51 + 288LL))(v51);
if ( v52 )
break;
__syslog_chk(3LL, 2LL, "%s:%d assert issuer is null", "sso_saml.cpp", 399);
LABEL_44:
if ( v86 == ++v47 )
goto LABEL_45;
}
v53 = (char *)v52
+ *(_QWORD *)(*v52 - 336LL)
+ *(_QWORD *)(*(_QWORD *)((char *)v52 + *(_QWORD *)(*v52 - 336LL)) - 256LL);
v54 = (*(__int64 (__fastcall **)(char *, _QWORD))(*(_QWORD *)v53 + 152LL))(v53, 0LL);
v55 = *v15;
v56 = (char *)xercesc_3_2::XMLString::transcode(v54, *v15);
v95 = v56;
// [...]
xercesc_3_2::XMLString::trim((xercesc_3_2::XMLString *)v56, v55);
v57 = v95;
// get issuer and check
v61 = (const char *)v103;
v62 = *(_QWORD *)(a1 + 128);
if ( v62 != v104 || v62 && memcmp(*(const void **)(a1 + 120), v103, v62) )
{
// [...]
}
if ( !(*(__int64 (__fastcall **)(_QWORD))(*(_QWORD *)*v47 + 368LL))(*v47) ) // virtual thunk to opensaml::saml2::AssertionImpl::getConditions() const
goto LABEL_58; // nhảy
// [...]
LABEL_58:
v68 = (_QWORD *)(*(__int64 (__fastcall **)(__int64))(*(_QWORD *)v50 + 264LL))(v50); // virtual thunk to opensaml::saml2::SubjectImpl::getNameID() const
v69 = (char *)v68
+ *(_QWORD *)(*v68 - 336LL)
+ *(_QWORD *)(*(_QWORD *)((char *)v68 + *(_QWORD *)(*v68 - 336LL)) - 256LL);
v70 = (*(__int64 (__fastcall **)(char *, _QWORD))(*(_QWORD *)v69 + 152LL))(v69, 0LL); // virtual thunk to xmltooling::AbstractSimpleElement::getTextContent(unsigned int) const
v71 = *v15;
v72 = (char *)xercesc_3_2::XMLString::transcode(v70, *v15);
v96 = v72;
if ( !v72 )
{
v106 = &src.m128i_u64[1];
LABEL_100:
std::__throw_logic_error("basic_string: construction from null is not valid");
}
xercesc_3_2::XMLString::trim((xercesc_3_2::XMLString *)v72, v71);
v73 = v96;
// std::string src = string::string(nameID, len(nameID))
// a1+248 = nameID, a1+256 = size
// [...]
v63 = opensaml::SAMLConfig::getConfig((opensaml::SAMLConfig *)v99);
return (*(__int64 (__fastcall **)(__int64, __int64))(*(_QWORD *)v63 + 24LL))(v63, 1LL);
}
Bypassing Authentication via SAML SSO
Inside the verifySamlResponse function, when checking the signature at steps 4 and 10a,

long SSOSAMLClient::VerifySamlResponse(const std::string& b64Response)
{
[...]
if (this->verifyMode == "response" || this->verifyMode == "both") {
auto* signable = dynamic_cast<xmltooling::SignableObject*>(src);
if (!signable)
throw std::runtime_error("signable is null");
if (!signable->getSignature())
throw std::runtime_error("getSignature is null");
verifySignature(signable->getSignature());
[...]
for (opensaml::saml2::Assertion* assertion : assertions) {
if (this->verifyMode == "assertion" || this->verifyMode == "both")
verifySignature(assertion->getSignature());
[...]
}
Here, the developer implicitly assumed that the value of verifyMode would always be one of three options: {"response", "assertion", "both"}. As a result, if verifyMode holds any value other than those three, the signature verification step can be completely bypassed.
The value of verifyMode is assigned within the SSOSAMLClient constructor. Looking at the SSOSAMLClient constructor, if readSAMLConfig returns false, the constructor returns early, leaving the object with its attributes defaulted to empty strings or false


readSAMLConfig:
bool SSOSAMLClient::readSAMLConfig(Json::Value& out)
{
static const char* kConfPath = "/usr/syno/etc/ssoclient/saml.conf";
RunAs guard(0, 0, "sso_saml.cpp", 494);
if (SLIBCFileExist(kConfPath)) {
if (SLIBCFileExist(kConfPath) == 1) {
if (!out.fromFile(kConfPath)) {
syslog(LOG_ERR, "%s:%d failed to load json file", "sso_saml.cpp", 506);
throw std::runtime_error("failed to load json file");
}
}
if (!out.isMember("saml_response_signature"))
out["saml_response_signature"] = "response";
return true;
}
out["sso_saml_enable"] = "";
out["saml_name"] = "SAML";
out["saml_idp_entity_id"] = "";
out["saml_idp_signin_url"] = "";
out["saml_acs_url"] = "";
out["saml_idp_certificate"] = "";
out["saml_valid_date"] = "";
out["saml_idp_certificate_expired"] = "";
return false;
}
__int64 __fastcall SYNO::SSO::SSOSAMLClient::readSAMLConfig(SYNO::SSO::SSOSAMLClient *this, Json::Value *a2)
{
// [...]
if ( (unsigned int)SLIBCFileExist("/usr/syno/etc/ssoclient/saml.conf") )
{
// load config from file
return 1LL;
}
else
{
// return default config
Json::Value::Value((Json::Value *)v29, "");
v11 = Json::Value::operator[](a2, "sso_saml_enable");
Json::Value::operator=(v11, v29);
Json::Value::~Value((Json::Value *)v29);
Json::Value::Value((Json::Value *)v29, "SAML");
v12 = Json::Value::operator[](a2, "saml_name");
Json::Value::operator=(v12, v29);
Json::Value::~Value((Json::Value *)v29);
Json::Value::Value((Json::Value *)v29, "");
v13 = Json::Value::operator[](a2, "saml_idp_entity_id");
Json::Value::operator=(v13, v29);
Json::Value::~Value((Json::Value *)v29);
Json::Value::Value((Json::Value *)v29, "");
v14 = Json::Value::operator[](a2, "saml_idp_signin_url");
Json::Value::operator=(v14, v29);
Json::Value::~Value((Json::Value *)v29);
Json::Value::Value((Json::Value *)v29, "");
v15 = Json::Value::operator[](a2, "saml_acs_url");
Json::Value::operator=(v15, v29);
Json::Value::~Value((Json::Value *)v29);
Json::Value::Value((Json::Value *)v29, "");
v16 = Json::Value::operator[](a2, "saml_idp_certificate");
Json::Value::operator=(v16, v29);
Json::Value::~Value((Json::Value *)v29);
Json::Value::Value((Json::Value *)v29, "");
v17 = Json::Value::operator[](a2, "saml_valid_date");
Json::Value::operator=(v17, v29);
Json::Value::~Value((Json::Value *)v29);
Json::Value::Value((Json::Value *)v29, "");
v18 = Json::Value::operator[](a2, "saml_idp_certificate_expired");
Json::Value::operator=(v18, v29);
Json::Value::~Value((Json::Value *)v29);
// [...]
return 0LL;
}
}
When /usr/syno/etc/ssoclient/saml.conf doesn’t exist, verifyMode is "", which lets us bypass signature verification. This file is simply absent whenever SAML SSO has never been enabled on the system. Tracing execution from samlAuth through the SSOSAMLClient constructor to verifySamlResponse, there’s no check for whether SAML SSO is actually enabled.
From here, we just need to craft a valid SAML Response. Most elements checked in verifySamlResponse are only superficially validated, so they’re easy to satisfy. The one exception is the Issuer, checked at step 8 (with a similar condition in step 10b for each Assertion), which must be non-empty.

After that, the Issuer must pass a comparison against the Issuer retrieved from SSOSAMLClient. Since the Issuer from SSOSAMLClient will have a value of "", the Issuer value we provide in the SAML Response must also evaluate to "". An Issuer that is simultaneously empty and not empty cannot exist. Fortunately for us, however, the Issuer is trimmed before this comparison takes place:
In this case, an Issuer value containing just a <space> will satisfy the condition, and it will subsequently become "" after passing through the trim() function.
At this point, it is just a standard string comparison in C++. Since both values are now "", we naturally pass this check.
Even though we have now satisfied all the requirements, the login will still fail:
This is due to the check at step [3] in samlAuth:
bool qualified = std::strchr(samlUser.c_str(), '\\')
|| std::strchr(samlUser.c_str(), '@');
if (!qualified && !client.m_allowLocalUser) { // [3]
syslog(LOG_ERR, "%s:%d user is local: %s, %s",
"saml_auth.cpp", 40, samlUser.c_str(), realName);
return 2;
}
Our saml_match_local_user in SSOSAMLClient is currently set to false, meaning we can only log into LDAP or AD (Active Directory) accounts at this point.
According to ZDI’s requirements, providing a pre-known username is considered unrealistic and therefore unacceptable. However, ZDI does accept the fact that the default AD admin account follows the format <NetBIOS>/Administrator. The NetBIOS name can be easily obtained through enumeration on Synology NAS. Once logged in successfully as an administrator, there are multiple ways to achieve RCE using various admin-privileged features.
The whole bug is a chain of small assumptions that happen to line up perfectly. Here’s what it looks like end to end.
The SAML endpoint is live even when SAML SSO has never been configured. Nothing in samlAuth, the SSOSAMLClient constructor, or verifySamlResponse checks whether the feature is actually enabled.
A missing config silently degrades to insecure defaults. If /usr/syno/etc/ssoclient/saml.conf doesn’t exist, readSAMLConfig returns false and the constructor exits early, leaving all fields at their zero values: empty strings and false. That means verifyMode is "" and saml_match_local_user is false.
An unexpected verifyMode value disables signature verification entirely. The check only runs when verifyMode is one of "response", "assertion", or "both". The developer didn’t handle the fallthrough, so verifyMode == "" skips signature checking altogether. You can forge a SAML Response with no valid signature.
The one strict check gets defeated by normalization. The Issuer must be non-empty, but must also equal the client’s Issuer, which is "". That looks like a contradiction – until you see that Issuer is trim()-ed before the comparison. An Issuer of a single space passes the non-empty check and then collapses to "", satisfying both conditions at once.
The last gate narrows the target but doesn’t close it. With saml_match_local_user set to false, local accounts are rejected and only usernames containing \ or @ are allowed (LDAP/AD users). The default AD admin <NetBIOS>\Administrator fits, and the NetBIOS name is trivially enumerable on a Synology NAS.
On any Synology DS925+ system that has never enabled SAML SSO (the default) and uses AD or LDAP, an unauthenticated attacker can forge an unsigned SAML Response, skip authentication entirely, and log in as domain administrator. RCE follows from any admin-privileged feature.
The root cause isn’t a single line. A disabled feature is still reachable. A config load failure defaults to insecure values instead of refusing to proceed. An unrecognized verify mode is treated as “skip verification” rather than “deny.” Each assumption is small. Together they’re a full pre-auth bypass.

Conclusion
Although it’s a bit of a pity that I found the bug too late to get the visa sorted in time to be onsite and experience the competition atmosphere in person, this was still a successful Pwn2Own participation for me. A shoutout to @ngocquy0307 for collaborating and providing a lot of support during the bug-hunting process, to @tuo4n8 for supporting me throughout the competition, to @lemauanhphong for helping me bring the bug to the contest, and last but not least, a huge thanks to Verichains for supporting me in participating in the competition.