[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 in the actual bug hunting. The only place AI came in was afterward - to decompile the code into clean, readable C++ for this writeup.
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;
}
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
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
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);
}
After reading through the snippets above, you’ve probably already noticed the flaw in the application’s logic within the SAML SSO feature. Now, let’s dive into the detailed analysis.
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;
}
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.
Summary
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.










