|
1 | 1 | require File.expand_path("test/test_helper") |
2 | 2 |
|
3 | 3 | describe Samlr do |
4 | | - describe "invalid multiple saml responses" do |
| 4 | + # Zendesk acts as a SAML Service Provider (SP). |
| 5 | + describe "signature wrapping attack" do |
5 | 6 | let(:shared_id) { Samlr::Tools.uuid } |
6 | | - let(:xml_response_doc) do |
7 | | - options = { |
8 | | - :destination => "https://example.org/saml/endpoint", |
9 | | - :in_response_to => Samlr::Tools.uuid, |
10 | | - |
11 | | - :audience => "example.org", |
12 | | - :not_on_or_after => Samlr::Tools::Timestamp.stamp(Time.now + 60), |
13 | | - :not_before => Samlr::Tools::Timestamp.stamp(Time.now - 60), |
14 | | - :response_id => Samlr::Tools.uuid, |
15 | | - skip_conditions: true, |
16 | | - sign_response: false, |
17 | | - sign_assertion: false, |
18 | | - assertion_id: shared_id, |
19 | | - certificate: TEST_CERTIFICATE |
20 | | - } |
21 | | - Samlr::Tools::ResponseBuilder.build(options) |
22 | | - end |
| 7 | + # Most IdPs publish a public, signed metadata file. |
| 8 | + # - The signature (<SignatureValue>) covers ONLY this EntityDescriptor and matches the data/ID exactly. |
| 9 | + # - This is meant to be public so SPs (like Zendesk) can get the IdP’s public keys for SSO. |
23 | 10 | let(:xml_metadata_doc) do |
24 | 11 | options = { |
25 | 12 | :entity_id => "https://sp.example.com/saml2", |
26 | 13 | :name_identity_format => "identity_format", |
27 | 14 | :consumer_service_url => "https://support.sp.example.com/", |
28 | 15 | :sign_metadata => true, |
29 | | - metadata_id: shared_id, |
30 | | - certificate: TEST_CERTIFICATE |
| 16 | + :metadata_id => shared_id, |
| 17 | + :certificate => TEST_CERTIFICATE |
31 | 18 | } |
32 | 19 | Samlr::Tools::MetadataBuilder.build(options) |
33 | 20 | end |
34 | | - |
35 | 21 | let(:fingerprint) { Samlr::Certificate.new(TEST_CERTIFICATE.x509).fingerprint.value } |
36 | | - let(:saml_response) { Samlr::Response.new(Base64.encode64(xml_response_doc), fingerprint: fingerprint) } |
37 | 22 |
|
38 | | - it "succeeds" do |
| 23 | + it "is prevented by rejecting duplicate XML ID references" do |
| 24 | + # The attacker crafts their own <Assertion>, filling in any identity/attributes. |
| 25 | + options = { |
| 26 | + :destination => "https://example.org/saml/endpoint", |
| 27 | + :in_response_to => Samlr::Tools.uuid, |
| 28 | + |
| 29 | + :audience => "example.org", |
| 30 | + :not_on_or_after => Samlr::Tools::Timestamp.stamp(Time.now + 60), |
| 31 | + :not_before => Samlr::Tools::Timestamp.stamp(Time.now - 60), |
| 32 | + :response_id => Samlr::Tools.uuid, |
| 33 | + :skip_conditions => true, |
| 34 | + # This Assertion is not signed - meaning the attacker can put anything they want for the user, roles, etc. |
| 35 | + :sign_assertion => false, |
| 36 | + :sign_response => false, |
| 37 | + :assertion_id => shared_id, |
| 38 | + :certificate => TEST_CERTIFICATE |
| 39 | + } |
| 40 | + xml_response_doc = Samlr::Tools::ResponseBuilder.build(options) |
| 41 | + |
39 | 42 | metadata_doc = Nokogiri::XML(xml_metadata_doc) |
40 | 43 | response_doc = Nokogiri::XML(xml_response_doc) |
41 | 44 |
|
42 | | - metadata_signature_doc = metadata_doc.xpath("md:EntityDescriptor/ds:Signature", Samlr::NS_MAP).first |
43 | | - metadata_entity_descriptor_doc = metadata_doc.xpath("md:EntityDescriptor", Samlr::NS_MAP).first |
44 | | - |
45 | 45 | assertion_doc = response_doc.xpath("/samlp:Response/saml:Assertion", Samlr::NS_MAP).first |
46 | | - assertion_doc.xpath("saml:Subject/saml:NameID").first.content = "[email protected]" |
| 46 | + # The attacker embeds the entire valid, signed EntityDescriptor inside the assertion. |
| 47 | + # Now there are two elements with same ID in the same doc: |
| 48 | + # - The outer Assertion (attacker’s, unsigned, forged) |
| 49 | + # - The nested EntityDescriptor (genuine, validly signed, but only public metadata) |
| 50 | + metadata_entity_descriptor_doc = metadata_doc.xpath("md:EntityDescriptor", Samlr::NS_MAP).first |
47 | 51 | assertion_doc.xpath("saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData", Samlr::NS_MAP).first.add_child(metadata_entity_descriptor_doc.dup) |
| 52 | + # The attacker places everything in a single SAML Response document, |
| 53 | + # and uses the real <Signature> (copied from metadata) at the <Response> or <Assertion> level referencing the duplicate ID. |
| 54 | + # |
| 55 | + # The public signature and certificate are left untouched - copied exactly as found. |
| 56 | + # Only the outer Assertion is attacker-controlled (and unsigned). |
| 57 | + # The document is constructed so that, if a SAML parser looks for a valid signature on any element with the shared ID (and not just on the assertion), |
| 58 | + # it will pass signature validation on the wrong node (EntityDescriptor), and allows an attacker to log in with the fake assertion. |
| 59 | + # According to SAML schema, the <Signature> needs to be placed after <Issuer>. |
| 60 | + metadata_signature_doc = metadata_doc.xpath("md:EntityDescriptor/ds:Signature", Samlr::NS_MAP).first |
48 | 61 | response_doc.at("/samlp:Response/saml:Issuer", Samlr::NS_MAP).add_next_sibling(metadata_signature_doc.dup) |
49 | 62 |
|
50 | 63 | crafted_saml_response = Samlr::Response.new(Base64.encode64(response_doc.to_xml), fingerprint: fingerprint) |
| 64 | + |
| 65 | + # Checks for duplicate values of the ID attribute. |
| 66 | + # If two elements share the same ID, immediately treats the entire SAML Response as invalid. |
| 67 | + # Signature references (<Reference URI="#META123"/>) must always point to exactly one unique element according to XML DSig/SAML spec. |
| 68 | + # By rejecting duplicates, the signature can never be validated against a wrong element, like a nested public metadata node. |
51 | 69 | error = assert_raises(Samlr::SignatureError) { crafted_saml_response.verify! } |
52 | 70 | assert_equal "Expected 1 element with id #{shared_id}, found 2", error.details |
53 | 71 | end |
| 72 | + |
| 73 | + it "is prevented by not allowing signed and unsigned assertions with same ID" do |
| 74 | + options = { |
| 75 | + :destination => "https://example.org/saml/endpoint", |
| 76 | + :in_response_to => Samlr::Tools.uuid, |
| 77 | + |
| 78 | + :audience => "example.org", |
| 79 | + :not_on_or_after => Samlr::Tools::Timestamp.stamp(Time.now + 60), |
| 80 | + :not_before => Samlr::Tools::Timestamp.stamp(Time.now - 60), |
| 81 | + :assertion_id => shared_id, |
| 82 | + :skip_conditions => true, |
| 83 | + :sign_assertion => true, |
| 84 | + :sign_response => false, |
| 85 | + :certificate => TEST_CERTIFICATE |
| 86 | + } |
| 87 | + xml_response_doc = Samlr::Tools::ResponseBuilder.build(options) |
| 88 | + response_doc = Nokogiri::XML(xml_response_doc) |
| 89 | + assertion_doc = response_doc.xpath("/samlp:Response/saml:Assertion", Samlr::NS_MAP).first.dup |
| 90 | + # Duplicating the assertion with same ID, modified data and signature removed. |
| 91 | + assertion_doc.xpath("saml:Subject/saml:NameID").first.content = "[email protected]" |
| 92 | + assertion_doc.xpath("ds:Signature", Samlr::NS_MAP).first.remove |
| 93 | + response_doc.xpath("/samlp:Response", Samlr::NS_MAP).at("./samlp:Status", Samlr::NS_MAP).add_next_sibling(assertion_doc) |
| 94 | + |
| 95 | + # Never processes login data from any assertion or attribute that is NOT covered by a valid signature. |
| 96 | + # It won't accept SAML Response with two assertions sharing same ID. |
| 97 | + error = assert_raises(Samlr::SamlrError) { Samlr::Response.new(Base64.encode64(response_doc.to_xml(Samlr::COMPACT)), fingerprint: fingerprint) } |
| 98 | + assert_equal "Schema validation failed", error.message |
| 99 | + assert_match %r{Assertion', attribute 'ID': '#{shared_id}' is not a valid value of the atomic type 'xs:ID'}, error.details |
| 100 | + end |
| 101 | + |
| 102 | + it "is prevented by not allowing many assertions - signed and unsigned" do |
| 103 | + options = { |
| 104 | + :destination => "https://example.org/saml/endpoint", |
| 105 | + :in_response_to => Samlr::Tools.uuid, |
| 106 | + |
| 107 | + :audience => "example.org", |
| 108 | + :not_on_or_after => Samlr::Tools::Timestamp.stamp(Time.now + 60), |
| 109 | + :not_before => Samlr::Tools::Timestamp.stamp(Time.now - 60), |
| 110 | + :assertion_id => shared_id, |
| 111 | + :skip_conditions => true, |
| 112 | + :sign_assertion => true, |
| 113 | + :sign_response => false, |
| 114 | + :certificate => TEST_CERTIFICATE |
| 115 | + } |
| 116 | + xml_response_doc = Samlr::Tools::ResponseBuilder.build(options) |
| 117 | + response_doc = Nokogiri::XML(xml_response_doc) |
| 118 | + assertion_doc = response_doc.xpath("/samlp:Response/saml:Assertion", Samlr::NS_MAP).first.dup |
| 119 | + # Duplicating the assertion with different ID, modified data and signature removed. |
| 120 | + assertion_doc.xpath("saml:Subject/saml:NameID").first.content = "[email protected]" |
| 121 | + assertion_doc['ID'] = Samlr::Tools.uuid |
| 122 | + assertion_doc.xpath("ds:Signature", Samlr::NS_MAP).first.remove |
| 123 | + response_doc.xpath("/samlp:Response", Samlr::NS_MAP).at("./samlp:Status", Samlr::NS_MAP).add_next_sibling(assertion_doc) |
| 124 | + |
| 125 | + # Never processes login data from any assertion or attribute that is NOT covered by a valid signature. |
| 126 | + # It won't accept SAML Response with two assertions. |
| 127 | + crafted_saml_response = Samlr::Response.new(Base64.encode64(response_doc.to_xml(Samlr::COMPACT)), fingerprint: fingerprint) |
| 128 | + error = assert_raises(Samlr::FormatError) { crafted_saml_response.verify! } |
| 129 | + assert_equal "Invalid SAML response: unexpected number of assertions", error.message |
| 130 | + end |
54 | 131 | end |
55 | 132 | end |
0 commit comments