As the introduction to the excellent Matasano Crypto Challenges says,

There are tens, probably hundreds, of obscure little things you can do to take a cryptosystem that should be secure even against an adversary with more CPU cores than there are atoms in the solar system, and make it solvable with a Perl script and 15 seconds.

So as to not bury the lede: Older versions of Steam allow an attacker who observes a client connecting to Steam to read sensitive information sent over the network. This allows the attacker to take over the account, bypass SteamGuard, and sometimes view plain-text passwords.

But how? Steam encrypts its entire network connection (at least the Steam-specific parts; there are some suspicious plaintext HTTP requests going around) with AES-256-CBC. And the AES key used (hereafter “session key”) is generated securely on the client, encrypted with RSA-1024 and a hardcoded public key, and sent to Steam; an eavesdropper can’t get at the session key.

RSA and AES aren’t broken- but Steam was.

Steam’s encryption needed to do two things. First, the most obvious- it needed to keep data secret, so that an attacker intercepting a user’s connection to Steam can’t see their Steam password or other sensitive data. Second, it needed to provide authenticity- it needed to keep an attacker from tampering with data sent between a user and Steam.

Authenticity might not seem necessary- who cares if an attacker can change the password a user sends to Steam, as long as they can’t read it? But, imagine if an attacker changed the anti-cheat heartbeats a user playing a VAC-secured game sent to Steam- they could get an innocent user banned. Or, imagine an attacker sending a trade to a user for all of their items, but changing the trade so that the user thinks they’re not giving anything away.

Usually, a Message Authentication Code (MAC) would be used to provide authenticity- this code would make sure that an attacker who tried to make changes to data would be immediately found out. But, it turns out, Steam didn’t use a MAC. Its encryption was completely unauthenticated.

Oops.

Although the lack of authentication alone is a vulnerability, exploiting it for meaningful gain would be hard- it turns out that the interesting parts of trading go over HTTPS, so changing items involved in a trade wouldn’t be possible. An attacker could get victims VAC-banned, but there’s no particular motive for that. Remember, they’d have to be in a position to intercept somebody’s connection to Steam- at a LAN party, for example. The attacks possible here are more academic than anything else.

If there was a way, however, to decrypt encrypted messages instead of just modifying them… now that’s a vulnerability! There’s loads of sensitive data sent over Steam’s protocol. Just take a look at a few choice fields from CMsgClientLogon (just one message sent over the channel):

optional string account_name = 50; optional string password = 51; optional string login_key = 60; optional bytes sha_sentryfile = 83;

The login_key field is used in lieu of the password if the user has Steam set to remember their password. And the sha_sentryfile field is used to uniquely identify the user’s machine to Steam, for SteamGuard. Capturing an account_name, password or login_key, and sentry file hash is equivalent to gaining access to the account.

But, alas, we can only modify things- not read them. Unless there’s something silly like a padding oracle attack in play… … Oh boy.

Was Steam really vulnerable to a padding oracle attack? There was a simple test: I took a copy of PySteamKit (the real SteamKit is a .NET program, and I didn’t want to mess with Mono), and patched its encryption routines to either generate correctly-padded garbage data or incorrectly-padded garbage data when a flag was set. Then, I just connected to Steam normally with this patched PySteamKit, set either one flag or the other immediately before logging in, and watched for differences.

When I sent messages with invalid padding, Steam just stopped responding to me; it silently dropped all my messages.

When I sent messages with valid padding that decrypted successfully (but decrypted to things where the end was corrupted), Steam gave me an explicit error response back. I noticed that the message it was responding to was CMsgClientLogon (the one with all those juicy fields); it’d respond with either EResult 5 “InvalidPassword” or EResult 7 “InvalidProtocolVer”).

Almost a textbook padding oracle- and at this point we reported the issue to Valve. Meanwhile, we kept working on a proof-of-concept.

The attack was a man-in-the-middle on an established session. That dramatically limited its effectiveness- some corrupt messages would make Steam close the connection. If that happened, that was pretty much it; Steam will reconnect, but it takes a very long time- on the order of seconds. An attacker would need access to a victim’s network connection, the ability to modify (not just read) packets in transit, and a large amount of time- maybe even days!- in which the victim doesn’t notice their Steam connection constantly restarting.

Maybe there was a better way?

Zombie Sessions

Let’s take a closer look at the way session keys are established. It works like this:

Some initial handshaking and setup. The client generates a random AES-256 session key, and encrypts it with RSA-1024 with Valve’s hardcoded public key. Then the client asks the server to encrypt the channel, by sending the encrypted session key in a ChannelEncryptResponse message. The server receives that, and replies with a ChannelEncryptResult. Communication between the client and the server is now encrypted with the session key.

If I can capture that ChannelEncryptResponse message, I can just send the encrypted session key from the session I want to target to Steam, instead of generating one on my own. Then, Steam will use whatever key my victim’s session used. I don’t know it, but Steam does; and Steam will do decryption, and be a padding oracle, with that key.

Because of this replay attack, I could take a simple .PCAP of somebody connecting to Steam, and decrypt that entire session. No need for MITM- just the ability to eavesdrop. No issues with time- as long as I see the start of the connection, I’ve got what I need. And if the connection was closed, I could just restart it. I could even run the attack in parallel, between all Steam servers, massively speeding it up.

I started by implementing my own toy Steam client, one just smart enough to go through the initial handshaking and establishment of session keys. Then I could just send ciphertexts and tell whether their padding was valid or not.

I modified my PoC to parse a PCAP and pull out the ChannelEncryptResponse, and the encrypted messages sent afterwards- those are the ones I wanted to decrypt. The big target was, of course, the CMsgClientLogon and the secrets it contained.

Then I received an email from Valve-

In terms of mitigation we are going to take a two stage approach. First, we are fixing the server code to not respond to invalid CMsgClientLogon protobuf messages. We’ve actually just updated the servers now with the fix for this that we’ve been working on and testing this morning. We believe this will mitigate the most obvious and important attacks that could be possible against logon credentials.

This was an amazingly fast response- I reported this at 3:12AM and they had mitigations deployed by 2:45PM. Excellent turnaround time.

On the other hand- crap! Now I don’t get to write a neat PoC!

But wait. It’s just the logon message that gets no reply. What about other ones?

I can write my padding oracle to work entirely by appending blocks to the end of any ciphertext I want that gets a response. Steam uses protocol buffers, which should generally ignore any extra stuff on the end they don’t recognize (for forwards compatibility.) So I just need one message that gets a different response, and the attack is back in full force!

And… after all that… I ran my PoC program with just a single PCAP of me signing into a test account, and… got a bunch of garbage back. Turns out Steam’s crypto library, CryptoPP, doesn’t actually validate all the padding. It only checks the last 4 bits of each byte for the length. So a message ending in 0x41 was considered to have valid padding, as was 0xF2, 0xF2… Luckily, 0xE2, 0xF2 was not valid- so each decryption had only 16 actual possibilities. Testing through them wasn’t hard. And, after all this, I saw my plaintext username, password, and sentry file hash pop out at me.

Phew.

Soon enough, Valve rolled out new crypto code- first in the Steam beta branch, then in the release branch. This uses a MAC (although in a somewhat concerning way- it’s part of the encrypted IV.), and even includes a nonce in the ChannelEncryptRequest message that the client must include in the ChannelEncryptResponse to prevent replay attacks. They even awarded Zemnmez and me Burning Flames Finder’s Fees, and I got into Valve’s Security Hall of Fame.