An informal security assessment of Imzy (part 1)

One of my hobbies is finding security vulnerabilities in websites—it's a relaxing way to unwind in the evening. A few months ago I asked a friend at Imzy if they'd like me to poke around. Imzy was intended as a place for online communities that don't suck. The community was in fact super nice, but unfortunately this was one of the many startups that Madeth It Not. They're shutting down soon. In any event, I had a good time and found some fun bugs.

Final screenshot of imzy.com before closing Final homepage of Imzy.com

Reconaissance

I only looked at imzy.com and associated domains -- I did not attempt to discover non-user-facing sites such as issue trackers at this time.

Format

  • imzy.com is largely a Single-Page Application (SPA). From an attacker's perspective, this is great, because it means that a goodly bit of their web logic gets shipped to the browser.
  • Most of the JS is compressed and mangled into https://cdn.imzy.com/20170113223923/js/app.js (versioned by timestamp.) There was not a corresponding source map file at the time I did this work, so I prettified it with JS Beautifier. (Later a source map appeared.)
  • The site uses React, which removes an entire class of XSS vulnerabilities.

Auth

The www.imzy.com site appears to be a rendering wrapper around an API server routed by nginx at www.imzy.com/api. The site bears a token cookie containing a JWT auth token; this is passed via XHR to the API as an Authorization: Bearer token (and cookies are ignored.) Each response conveys a replacement token in a header with an advanced expiration field.

The token uses HS256 and contains my account name, account ID, expiration (as a version 1 UUID, rather surprisingly), and a session ID. Example: {"username" "phyzome", "account_id" "e052213a-112d-11e6-9f2f-72237ec13009", "state" nil, "jti" "83e97258-f3c3-11e6-a74f-258d64fce61f", "exp" "971e4968-f4b6-11e6-9874-e416a2888e72", "scope" [], "options" {"tfa_type" ""}} There's nothing secret here that shouldn't be visible to me, and JWT algorithm substitution fails.

Page resources

Other things I see go by in the network monitor:

  • www.imzy.com is a CNAME to w.shared.global.fastly.net.; Fastly is a CDN.
  • JS being loaded from cdnjs.cloudflare.com; another CDN.
  • Websocket connections to {comments,typing,notifications}.imzy.com with the JWT token in the query params
  • Images loaded from an image CDN: images.imzy.com is a CNAME to imzy-default.imgix.net. and both are used; the latter itself is a CNAME to prod.imgix.map.fastlylb.net.. (Interesting, that prod -- but I didn't find a qa or dev.) Imgix allows on-the-fly transformation of images (cropping, resizing, etc.) sourced from S3. Imgix is then a customer of Fastly.
  • Imzy loads intercom.io's chat interface as well, which I did not probe.

Vulnerabilities

Cut to the chase, eh?

Information disclosure: Email verification token

After creating an account, the my-account resource /api/accounts/me returns JSON that includes email_token, the string that is normally sent along with email address verification emails. If I change my email address (to put myself into a "needs verification" state) and then fetch email_token from the API, I can POST the token to /api/auth/verify-email without having to retrieve that token from the email. This allows me to "verify" an email address I don't have access to. As a demonstration I set my email to definitely-staff@www.imzy.com, and "verified" it.

A few notes:

  • I was unable to change my email to an @imzy.com address! This somewhat inhibits a possible impersonation/escalation attack against Imzy staff. ("Hey, can you put the staff flag on this testing account? Thanks!")
  • If I fake verification for a real address, the owner of that address will receive an unexpected email. However, staff members might ignore it as possible testing; people who have never heard of Imzy would almost certainly either delete or spam it.

A little side bug: I found that under some circumstances, the email verification token did not change when an email address is changed. This has the same effect as #1 -- the user can verify their email once, change the email address, and re-use the token to falsely verify an address they do not own. I was not able to determine what caused this to happen, but it was true for several accounts.

HTTP Proxy: XSS and more

Imzy hosts a more or less straight HTTP proxy at https://www.imzy.com/i/proxy?url=... for showing external images in posts, probably to allow embedding HTTP URLs while avoiding mixed-content warnings. The proxy is almost entirely unvalidated. There are a number of bad consequences to this.

The simplest is Cross-Site Scripting (XSS). The proxy does not filter responses from the remote URL by Content-Type, and will pass through an entire HTML page complete with Content-Type: text/html header. If that page contains scripts, they will run in Imzy's security context and can steal session cookies, etc. I sent Imzy the URL https://www.imzy.com/i/proxy?url=https://lab.brainonfire.net/demo/imzy-20161012/proxy-xss.html as a demo; that page just has a alert(document.cookie) script as a proof of concept.

First steps towards mitigation would be to block the request if the proxied content-type would not be in an image content-type whitelist (and add a X-Content-Type-Options: nosniff header to protect Internet Explorer from its own stupidity.) That whitelist should be very small, containing perhaps just PNG, JPEG, and GIF. It should absolutely not contain SVG, which is an "image" format that can contain Javascript among other things.

HTTP Proxy: Flash XSS

Guess what? Content-Type and no-sniff aren't enough, because Flash is terrible. In researching mitigation for the above, I learned that Flash does its own content sniffing, bypassing those protections.

The impact would be stealing login cookies by embedding the object on the attacker's site, although I did not test this since I have no experience with Flash.

Mitigation would (apparently) start with setting the Content-Disposition: attachment header, but the best solution here would also involve exposing the proxy on a different subdomain where the token cookie would not be exposed; this is good defense-in-depth anyway.

HTTP Proxy: Latent denial of service

What if we limit the content-type, does that make this safe? No. Because the proxy passes all response headers through, an attacker can cause Imzy to parrot various security-sensitive headers. I sent a demo URL with the following theoretical attack code

php $imzyReal='z3XDsL9fRaBwjTnuAS+Yzn1kbiDpsRKHpWCbaX9s2c8='; $badPin='XXXXXX0000000000011111111112222222222228888='; header('Public-Key-Pins: max-age=900; pin-sha256="'.$imzyReal.'"; pin-sha256="'.$badPin.'";');

That sets an HPKP header on the response with a valid + an invalid TLS certificate checksum. I wasn't able to confirm this, but I think I got my browser to accept the pin.

If an attacker's website loaded that in the background, the browser of any victim viewing it would set an HPKP record for Imzy. The next time Imzy changed its TLS certificate, it would become inaccessible for that user. This is a latent DoS attack, but there are probably more immediate ones.

I haven't really explored what all this permitted, but suffice to say that only the content-type header should be proxied (and again only if it matches a whitelist!)

HTTP Proxy: Infrastructure access; SSRF

It gets worse. The proxy can make arbitrary HTTP GET calls and doesn't have a distinction between public and private URLs, so the proxy can be used to make requests to other servers in the same datacenter. (SSRF: Server-Side Request Forgery.) It is common to allow backend servers to make calls to each other without authorization tokens, so this might allow totally unauthenticated access to user data and admin systems. Most databases don't speak HTTP, but there are some twisted tricks you can do if you find yourself able to e.g. speak HTTP to a Redis DB. I deemed it too dangerous to experiment, in any case.

There's so much one can do with this sort of access. Instead of launching an all-out host-guessing session, I just used the proxy to do a port scan of localhost. It behaved differently for closed port vs. open-port-but-not-HTTP vs. open-and-HTTP. Any port that didn't give "Internal Server Error" was interesting. I found out not just what port the web service itself was listening on, but also that something else spoke HTTP! Prometheus Node Exporter, a monitoring and metrics server. I could even see what version it was running, so I took a look at its source on Github, and it was kinda scary. Lots of custom code to handle HTTP requests, byte by byte. No obvious exploits (but I don't know Golang). Anyway, the root page links to the /metrics endpoint so I took a look: https://www.imzy.com/i/proxy?url=http://localhost:12345/metrics

Most of the metrics were uninteresting, but one of them was a string metric that give me the web server's hostname. (Also the version of Linux that was in use.) I combined that with the earlier DB URL disclosure to try to find a pattern of hostnames, with little luck. I did find another web server, probably in the same cluster. With tools and time it's possible I could have gained shell access somewhere.

Mitigation here is a little tricky! Maybe the proxy needs to live on a different server in an entirely different network environment; maybe it needs to be run with special contraints that only give it access to internet-facing network interfaces. I suppose the best option would be resolving DNS for the URL's domain name and confirming that it is a public IP, but then you still have to figure out how to avoid rebinding attacks -- what if the attacker's DNS returned 1.2.3.4 on the first lookup, but 127.0.0.1 on the second lookup when the app's HTTP client did its own resolution? You'd have to rewrite the URL to use the resolved IP address, then explicitly set the Host header. Not awful, but not pleasant.

HTTP Proxy: Cover for malicious activity

Just to be exhaustive, it's worth noting that the proxy will always be at risk of people using it to probe or attack an unrelated website with GET traffic. The attack would appear to originate with Imzy. More benignly, people could use this proxy to hide their IP address and bypass georestrictions or web filters, at least before the Content-Type whitelist went into place.

Rate-limiting and access logs are probably the best one can do here.

HTTP Proxy: Epilogue

After my initial report of the proxy issues, Imzy put in a patch to filter on Content-Type. However, from experimentation it looks like they whitelisted image/* and blacklisted image/svg+xml; I found that image/i-made-this-up was permitted. I recommended moving to a straight whitelist approach: image/jpg, image/png, image/gif.

In fact, I fairly quickly found a working exploit against the wildcard/blacklist filter after thinking more about how the code probably looked and reading up on proxy bypasses: Content-Type: image/foo,text/html. I don't think Content-Type is supposed to be multi-value capable, but it looks like the browser picks that second value, while nginx (or whatever is doing the filtering) treats the whole thing as one value. (This is reminiscent of HTTP Parameter Pollution [PDF].) Parser mismatches are trouble; LANGSEC strikes again!

XSS in profile link

URLs for links in one's user profile (e.g. about.social.social_site) are only checked on the client, not the server, so I could send a PUT with "social_site": "javascript:alert(document.cookie)". This was the only classic XSS I found.

Information disclosure: DB URL

I found a collection of constants in one of the client-side files. Most of it was innocuous, but it contained some infrastructure details, including a database connection string. No password, but it told me 1) what DB was in use, 2) the datacenter-internal domain name, and 3) the port. The domain name is particularly interesting because it could help me guess at how other servers are named. Interestingly, I was able to resolve the domain name, albeit to a private IP.

Combined with the above SSRF vulnerability, this could potentially allow DB modifications.

Phishing hazard: Incorrect domain trimming in UI

Link posts on Imzy display a trimmed version of the link URL's domain name. This function is used to extract the domain name and strip www. off the front, if found:

javascript extractDomain: function(e) { if (!e) return ""; var t = ""; return t = e.indexOf("://") >= 0 ? e.split("/")[2] : e.split("/")[0], t = t.split(":")[0].replace(/www./i, ""), t.toLowerCase() }

The regex fails to escape the dot operator, so this regex will turn wwwXimzy.com into imzy.com and display that with the post. This could e.g. facilitate phishing, if the attacker were to register wwwximzy.com and post a link to it. The regex also does not anchor the match to the beginning of the domain name, so iwwwxmzy.com would also turn into imzy.com.

The deeper problem here is not using a URL-handling library. There are other vulnerabilities in this function, although due to coinciding constraints on the server side I was not able to exploit them and they will probably remain masked.

Summary

Imzy patched all of these, and for the most part quite quickly. I appreciated their commitment to user privacy and security. There was a clear pattern around missing or insufficient whitelists, which might reflect either the type of security issues that were most common, or the type that I look for first. I also learned later (after the shutdown) about some additional security measures they had in place, such as only allowing a designated server to have outbound connections to the internet.

I'm able to disclose all vulnerabilities without further coordination now that the site no longer exists.

A part 2 will follow, featuring a severe user privacy leak. As a teaser, I can say that it is one of the few times in my career as a programmer where it really mattered whether a sort was stable. Update: Part 2 is live.

Responses: 1 so far Feed icon

  1. A casual security assessment of Imzy (part 2) | Brain on Fire says:

    […] back. If you missed the first in this two-part series, you may wish to read the intro to that post first, because I'm just going to dive right […]

Self-service commenting is not yet reimplemented after the Wordpress migration, sorry! For now, you can respond by email; please indicate whether you're OK with having your response posted publicly (and if so, under what name).