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).

Update: I gave a talk on this attack at Boston Security Meetup in 2020, and the slides are available.

User identity on Imzy

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.

A cropped screenshot of my Imzy profiles dashboard, showing my timmc, phyzome, and anonymous profiles.
No one else will ever be able to see or know that any of your usernames are connected.

That's a bold claim! And users took it to heart, keeping a more professional profile (possibly under their "wallet name", to use a phrase I like) and one or more other profiles in which they felt more free to engage in random silliness, have frank conversations about medical, sexual, or relationship issues, or participate in heated political conversations. It was very important that users not be able to discover the "group" of profiles that belong to the same account, or even discern that two profiles belonged to the same account.

However... 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.

Profile search

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, looking for usernames containing dogg:

curl -isS ''

  {"profile_username": "doggiedogdogdogdog", "display_username": "doggiedogdogdogdog", "avatar_image_url": null},
  {"profile_username": "doggirl666", "display_username": "doggirl666", "avatar_image_url": null},
  {"profile_username": "gkdogg", "display_username": "gkdogg", "avatar_image_url": null},
  {"profile_username": "Iluvdoggos", "display_username": "Iluvdoggos", "avatar_image_url": ""},
  {"profile_username": "jessedogg", "display_username": "jessedogg", "avatar_image_url": null},
  {"profile_username": "Murrdogg", "display_username": "Murrdogg", "avatar_image_url": null},
  {"profile_username": "Robbdogg87", "display_username": "Robbdogg87", "avatar_image_url": null}

Except for a small number of experimental real-name-verified accounts, the profile_username and display_username were identical, and we're not concerned with the avatar URL here, so essentially the only information provided is the profile username.

This is all public information! So where's the vulnerability?

An unusual oracle

It's in the sort parameter.

After some experimentation, I determined that the API allowed sorting on any profile attribute. This included the visible fields here, as well as the profile's id (UUIDs, not that useful), created_at (date of profile creation), and is_staff (boolean, whether this is an Imzy employee's official profile). I couldn't get anything off of the user's account, because that was a separate table, so I couldn't sort by email address, mailing address, etc. But then I considered that the site would need some way to connect these to each other so the logged-in user could access all of them in the same session. This would likely be called account_id. And it worked.

This is a huge problem. If I have 3 profiles with very different names from each other (say, "timmc", "phyzome", and "staff"), and someone retrieves the entire profile list sorted by profile name, my profiles are going to be listed very far apart from each other in the response. But if they sort by account ID, my profiles (marked below) are all going to be right next to each other:

timmc    <---
phyzome  <---
staff    <---

(This is example data not drawn from an actual API call, in order to protect user privacy.)

Now, just looking at that list we can't say for sure which profiles belong to the same user. 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.

What makes this a critical vulnerability is if we then sort by -account_id, that is, reverse the sort order:

timmc    <---
phyzome  <---
staff    <---

Notice how the overall list of profiles has reversed, but the order of my profiles has not changed. That's because the database or application has used a stable sort. The primary sort key here is the account id, and the secondary sort key is likely something implementation-specific such as profile ID, profile creation time, or some database record order.

If you understand how that works, feel free to skip the next section.

Digression: Stable sorts

The database is using a stable sort, meaning items that compare as equal (here, have the same account ID) reliably do not switch position when the list is sorted. This is useful when you are sorting by multiple criteria. Wikipedia gives the example of sorting a deck of cards. If you first sort by rank (numeric value), and then sort by suit (clubs, diamonds, hearts, spades), you would expect to find the cards in this order: Two of clubs through ace of clubs, two of diamonds through ace of diamonds, etc. But if that second sort were not stable, it's possible you could end up with clubs before diamonds but within the clubs all the ranks might be out of order. A stable sort ensures that for any two items that compare the same according to the sort (all clubs cards are "equal" if you're sorting by suit), the preexisting order of those two cards is not disrupted.

In the case of the above example, there's nothing special about the order of "timmc", "phyzome", and "staff". What's important is that that arbitrary order isn't altered when sorting on the account ID.

Efficient profile grouping

Given that Imzy's API used a stable sort here, how do we link together profiles of the same user? The basic approach is: Sort by account ascending, and compare that to the reverse of the accounts descending.

Imagine we have 5 users with account IDs numbered 1 through 5, and user #2 has 3 profiles. I've written the (hidden) account ID in square brackets, with the (public) profile name following. Here's the result of asking for profiles by ascending account ID:

ID [ASC]Profile

And then by descending account ID:

ID [DESC]Profile

Now reverse the results of descending account ID:


Now compare the account-ID-ascending response with this last reversed-account-ID-descending response:

AscendingDescending, reversed

Now any profiles that belong to the same account sort together, but switch places. You can find them by walking down the list until you encounter a mismatch. In the table above, Alice is in the same location on both lists, but we see that in the second row, we get Bob and Dave—a mismatch! On the right-hand list, skip down until we find Bob there as well. All the profiles from Dave down to Bob are the same user: Bob, Carol, and Dave. And then resume walking below that chunk of profiles.

The full exploit

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 by prefix searches (aaa, then aab, then 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 80,000+ results. (A very large number also worked.) This means we don't even need to worry about pagination!

Therefore, a complete exploit involves running these two queries, each of which takes 15-20 seconds to complete:

curl -sS ''
curl -sS ''

Here's what my actual attack looked like. (Note the reversed sense of negation in the sort order—a bug.) I was careful to not let this data hit disk, and to only peek at the vicinity of my own profiles, lest I risk seeing something I shouldn't:

# 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 '' > ~/tmp/ram/profiles-by-account-asc.json
curl -sS '' > ~/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

How to fix it

A fix involves validating the sort parameter against an allowlist of public profile attributes, setting pagination limits, and escaping LIKE query wildcards. Optionally, Imzy might have required authorization for this endpoint, at their discretion.

This "sort oracle" is one of the most interesting vulnerabilities I've encountered.

Further notes

  • The is_primary field on profiles was also sortable, which is of mild interest. (Indicates which profile is the user's primary.)
  • If I searched with @@@ instead, I also got all profiles. (@ was stripped from queries to support queries like "@tim", but after the length check.) This retrieved at least one additional profile, named "あ". This prompted me to take another look at validation of profile names...
  • 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 but accepted any suffix. I imagine that the vulnerable code looked something like url.startsWith(""). Naturally, I set up and set my avatar to a URL on that host:

curl -isS "" -X PUT \
  -H 'Content-Type: application/json' -H "Authorization: Bearer $IMZY_TOKEN" \
  --data '{"profileName": "stolf",
           "avatar_image_url": ""}'

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:



{"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.

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.

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 | grep -o '"remoteIp":"[^"]*"'

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 | grep -o '"sessionId":"[^"]*"' | head -n1

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.

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 '' \
  -H 'Authorization: Bearer ___' -F "file=@actually-svg.jpg"
{"original": "actually-svg.jpg",
 "filename": "",
 "data": {
   "Location": "",
   "Bucket": "imzy-default",
   "Key": "images/prod/communities/stolf-hax-logo-1494106466954.jpg",
   "ETag": "\"d25983e606033e8a51747fbec1b8015d-3\""

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

The resulting may now be used for an avatar, but that "jpg" is in fact a 15.5 MB SVG that has been produced by re-rendering a JPG as a raster of 1-pixel circles:

<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
<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)"/>

If I recall correctly, this is the result of uploading a JPG to Imzy and then asking their image CDN (Imgix) to present it as an SVG (via &fm=svg). 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?)

Seeing that Imzy accepted file formats that did not match their file name (the latter of which I recall was restricted) I attempted to upload various imagemagick exploits. But whoever was processing the initial uploads, whether it was Imzy or Imgix, 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.

The end

Well, that's all, folks. As I mentioned in the previous post, Imzy is gone (sadface!), so public disclosure coordination for most of these 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: 1 so far Feed icon

  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 […]

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).