Eight seconds on an open broker

A modern trading app feels alive because something is pushing data to it constantly. Prices tick, balances update, positions move, all without the page reloading. Behind that liveness is almost always a publish/subscribe broker. The browser opens a long-lived socket, subscribes to the topics it cares about, and the server streams messages as they happen.

That's efficient, and it has a sharp edge. A request/response API checks who you are on every call. A broker checks you once, at connect time, then trusts your subscriptions from there. If the connect check is missing and the subscription rules are loose, the broker will happily hand a stranger the firehose.

On a recent test of a centralized exchange, that's what it did. An anonymous client, no username, no token, connected to the production broker and subscribed to a wildcard topic. In one eight-second window it pulled 9,399 messages covering 4,447 distinct customer accounts. Balance, equity, credit, the amount available to withdraw, live derivatives positions, all streaming in real-time to nobody in particular.

Severity: CVSS 8.2 (High) · CWE-306, CWE-284
Exposed: real-time balance, equity, credit, withdrawal-available and open futures positions for 4,447 accounts (9,399 messages in 8 seconds). Anonymous publish was also accepted.

The shape of the mistake

The broker spoke MQTT over WebSocket, a common choice for browser-facing real-time data. The address wasn't even secret. It sat in a public client-side config file, because the front-end needs to know where to connect. That part is fine. The front-end is supposed to connect. The problem is what the front-end was allowed to do once it got there.

Two controls were missing and one was present, and it's the combination that did the damage.

Missing control one: authentication on connect. The broker accepted a connection with an empty client identifier and no credentials and returned success. Anyone on the internet could establish a session.

Missing control two: per-client topic rules on the sensitive namespaces. The customer data lived under a private/ topic tree. An anonymous client could subscribe to private/#, where the # is a wildcard meaning "and everything beneath this." One subscription, the entire private tree.

The control that was present is the telling part. When the same anonymous client tried to subscribe to the root wildcard # and to the broker's internal $SYS/# diagnostics tree, the broker refused both. So the access control system existed and was switched on. Someone configured rules. They just didn't extend them to the namespaces that carried customer balances. Two doors locked, the one that mattered left open.

# anonymous connect to the production broker, then subscribe to the private tree
$ mosquitto_sub --url 'wss://<broker>/mqtt' -i '' -t 'private/#' -v
# CONNECT   (no credentials, empty client-id)  -> CONNACK rc=0  Success
# SUBSCRIBE private/#                           -> SUBACK granted QoS 0
private/acct/4416****/login_balance  {"current-balance":1*****,"credit":*****,
                                      "withdrawal-available":1*****,"futures":[...]}
# 8s window: 9,399 messages, 4,447 distinct accounts

# the same anonymous client could also PUBLISH:
$ mosquitto_pub --url 'wss://<broker>/mqtt' -t 'public/ticker/BTCUSDT' -m '{"price":"1.00"}'
# PUBACK -> delivered to every subscribed app

The write side, which most testers skip

A broker has two verbs, subscribe and publish. Most people testing this kind of issue stop at the read. The write side is worth a look too.

The same anonymous client could publish. It sent a message, the broker acknowledged it and delivered it to subscribers. That opens a second class of abuse that has nothing to do with reading data:

Be precise about what this is: client-side deception. The injected messages change what the app shows, not what the backend believes. Real balances and real order matching live server-side and were untouched by a published message.

What we proved, and what we didn't

Here's the honest accounting.

It was a mass confidentiality failure. An unauthenticated party could harvest the financial profile of the entire customer base continuously, and could manipulate what users saw in their apps. For a regulated financial platform that's a serious privacy and data-protection problem on its own, and the read feed is a ready-made target list ranked by how much money each account can withdraw.

It wasn't account takeover, because the feed carried no credentials or tokens. It wasn't direct theft, because publishing a message doesn't move real funds. We chased the chain into the third-party trading server that might have escalated it further, and it turned out to be a dead end.

Spelling out what you didn't prove is part of the job. A defender can act on a scoped finding; an inflated one just gets argued down. The confidentiality impact stands on its own without it.

The fix

  1. Require authentication on connect. The broker should reject any client that does not present a valid, per user credential. Anonymous connect to a production broker carrying customer data should be impossible, not merely discouraged.
  2. Write per client topic rules and default to deny. A user may subscribe to their own private/<their id>/... and read the shared public/... feed, and nothing else. Wildcards that cross user boundaries must be refused the way # and $SYS/# already were.
  3. Make publishing to public and to other users' topics a server only right. Clients consume those feeds, they do not write them.
  4. Treat the broker as a first class asset in your security program. It often is not, because it is not a web server and not a database, so it slips between the teams that own each.

Prevent it in CI/CD: keep auth-on-connect and per-client ACLs as version-controlled config (deny-by-default), and add a release gate that an anonymous connect to the production broker is refused before ship.

Checking your own real-time layer

Four quick tests against any broker you run:

The push-based layer is easy to forget. It's neither a web app nor a database, so it falls outside the usual checklists, but it carries the same data as the API and trusts you after a single handshake. Get that handshake wrong and an eight-second connection walks off with the financial profile of thousands of customers.