Skip to content

Instantly share code, notes, and snippets.

@tkuester
Last active March 10, 2024 00:59
Show Gist options
  • Star 22 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save tkuester/67f2d8f5c03aee22c6d7 to your computer and use it in GitHub Desktop.
Save tkuester/67f2d8f5c03aee22c6d7 to your computer and use it in GitHub Desktop.
DEFCON23 / WCTF: Shock Collar as a Service

Vegas is the last place I ever expected to visit. And yet, I wound up tucking myself into the corner of the Wireless Village for three days, absorbing excellent talks on the state of wireless hackery. Though I hadn't planned to try any of the challenges, I got drawn into @dntlookbehindu's (aka Russ) "SDR Roulette".

Shock Collar

Russ purchased a dog collar to analyze the wireless communication between remote and receiver, and found out that it was vulnerable to replay attacks.

...shocking, isn't it?

Dohohoho

Fact of the matter is, if you buy a Chinese sub-GHz ISM wireless device, it's probably vulnerable to replay attack. Simple replay attacks are only a piece of the puzzle, though. There's so much more. By using SDR, I can get down to the protocol layer, and see what hackery I can manage. This is just a little write up of what I found towards the end of the contest, and my methods.

Would you like to play a game?

The rules of the game were simple, if not a little "Hacksaw-esque". Russ would activate the remote four times, transmitting commands for the four different modes of operation: beep, blink, vibrate, and shock. You could record the transmissions for replay, but he wouldn't tell you which transmission corresponded to what mode. The goal was to strap the collar to your leg, and retransmit one (or all) of the four messages. To win all the points, you needed to shock yourself. (Hackers will do anything for street cred.)

Russ dropped two key pieces of intel on us. First, the device transmitted at 433.92 MHz. Second, the shock value was set to level 20, since anything more was unpleasantly painful. Knowing this, there was no way I'd settle for just replay attacks. But first, I had to capture the signal.

While most people were happy to use GQRX to capture the signal, I prefer osmocom_fft. It's lighter weight, and a little simpler to use. I chose the following settings (approximately).

  • Tune Frequency: 434 MHz (Close enough, I'm lazy.)
  • Sample Rate: 1 Msps (The RTL-SDR doesn't like going slower...)
  • Gain: 10 dB (More than enough for close rx)
  • File Name: shock_collar_btn1-1MSPS.cfile

I like putting the sample rate in my file name. If you do enough signal processing, you'll learn how important it is to keep track of that value!

Aww yiss! OOK is what Tiggers do best!

Here's a look at what I saw while recording. That's On/Off Keying, if you're interested. It's a very simple modulation, much like morse code.

Retransmitting

Now that I had the captures, I couldn't resist the urge to retransmit the signal. Accuse me of something Freudian, but I just wanted to try out the BladeRF. Very quickly, this is what's happening inside GNU Radio.

In one end, out the other.

For those of you not familiar with GNU Radio, the Osmocom Source is an abstract interface for radios like the RTL-SDR, HackRF, and B200. Think of it like a tape recorder for radio signals. The .cfile is where we'll be storing our signal.

Obviously, this is an incredibly primitive example. There's no user interface to control the capture frequency or gain, and no way to visually verify you even have a signal to capture. This works, but osmocom_fft is much easier to use.)

Playing the signal back is just as simple. Hook the file block up to the Osmocom Sink, point it to the output frequency, and make sure you picked the right gain settings for your radio. (Obviously, you'd only be doing one of these at a time.)

Retransmitting a Cleaner Signal

Funnily enough, I was having trouble getting the signal to reach the dog collar. Perhaps it was my antenna, perhaps I just don't know how to use the BladeRF. In either case, part of the problem was this: I was retransmitting the noise I captured with my original signal.

Turns out, this isn't a new problem.

The Transatlantic cable had to deal with this too. Square-ish electrical pulses sent across the ocean would degrade completely by the time they reached Britain. Here's a peek at what the signal a might look like a few miles off shore.

Noisy, but still square-ish

Our signal has lost some of it's amplitude, and picked up some noise. Naively, you might think that amplifying the signal is enough... but remember: whatever gain you add to your signal, you also add to your noise.

Math isn't hard, see?

You can filter your signal a little to try and remove some of the noise, but you'll never be able to get rid of all of it... however, do we know enough about the signal to be able to regenerate it without noise? Since we know the original signal is only ever in one of two states, we can recreate a perfect copy as long as there's enough signal to work with.

The designers of the Transatlantic cable used this idea to their advantage. At periodic intervals, engineers placed "regenerators". After filtering out the noise and adding a little gain, the regenerator would recreate a fresh copy of the signal. Any time the signal was above zero, it would output a constant "one". When the amplitude drops below zero, it outputs a constant "neg one".

Clean that signal up!

Doing it with GNU Radio

Complex signals are complicated.

If you ever want to experiment with GNU Radio, these blocks are bread and butter. Getting familiar with them is essential!

  • The File Source reads the waveform as fast as it can from your HD
  • The Throttle slows the File Source down to simulate real time
  • The Complex to Mag block demodulates complex AM signals
  • The Threshold block outputs 1 when the signal is over the threshold, 0 otherwise
  • The QT GUI Time Sink shows you how your signal changes over time

The result looked something like this.

Look at them bits wiggle!

The blue waveform represents the raw demodulated AM signal we received. Notice how the amplitude changes as Russ walked around the room, and the "jitter" in the length of each pulse. There's even a little bit of noise! These are problems you run into with real transmitters!

The red waveform is the regenerated waveform, ready for transmit. Notice how much "cleaner" it looks when compared to the RF signal. Now, 100% of our transmission energy is going into the signal.

While it is true that I had a pretty good signal to noise ratio, that might not always be the case. Ie: sniffing on a transmitter further away from you. Like the cable, you could put a filter to clean up your signal. I chose to omit this, since I had good SNR.

Unremarkably, this packet format is similar to @samykamkar's garage door format. A lot of Chinese vendors like framing their "bits" with start and stop bits, because it removes the need to do proper clock recovery. Simply look for the positive edge, wait for a while, and decide if the bit is a one or a zero. This framing is very similar to the UART on your Arduino, but for an individual bit instead of a whole byte.

Chinese bit framing

One very important thing to understand here is the difference between a "bit" and a "symbol". In RF speak, a symbol doesn't directly pertain to the actual bits of information. In QPSK for example, each symbol could represent two bits, instead of one. This is the difference between baud rate (symbols / second) and bit rate (bits / second).

Now, how do we retransmit the regenerated waveform (in red above)? If you notice, the Osmocom Sink has a blue tab (complex floating point), but we're dealing with plain old floating point samples (orange tab). Complex samples are a notoriously difficult concept to wrap your head around... but if you're willing to accept some magic, this is how you create OOK from the regenerated red waveform.

Math is basically magic

Needless to say, I eventually got zapped. It was rather unnerving having the collar strapped to my leg. I got beep and flash for free, but when I got down to the last two "chambers", my heart was pounding. I'm quite glad the venerable TSA never went through with their ideas for airport security.

Although I managed to execute a replay attack, I'm still mostly blind about how the system functions. While the chances are pretty small, the designers could have implemented a weak rolling code that changes every ten minutes, or every ten button presses. Although it seems impractical, I'll get a better idea of how the system works if I can inspect the actual packet. Let's dive into the transmission.

These packets came at great cost...

If you're making your own communications system from the ground up, building the transmitter is the easiest part. You just throw your bits out on the air, and if the other guy doesn't get it, that's not your problem. However, I don't get to decide the format this time! I need to figure out what the packet format is before I build the transmitter. I could go the manual route and write the bits out with pencil and paper, but I'd rather build a full fledged receiver so I can get packets in real time.

I quickly wrote a little block in Python that uses a state machine to look at the incoming samples. When it sees a positive edge (indicating the start of a symbol), it counts the samples to the next negative edge. If the pulse is longer than a specified threshold, it records a one, otherwise it records a zero. After gathering a set number of bits, it prints them to the screen.

Here are the bits that I received from the four different RF captures.

010001000011010101000000000010100111011100
010000001011010101000000000010100011111100
010000100011010101000000000010100110111100
010000010011010101000000001100100101111100

I won't make you count, but there are 42 bits per message. That's just a little more than five bytes. (It's not too wild a guess to assume the receiver works with bytes.) If I pick the wrong "boundary" for the bytes, the packets might not make any sense. Fortunately, Russ dropped a huge hint. The shock level was set to 20. In binary, that's "00010100". Let's see if that's in the packets...

 |    ?   |    ?   |    ?   |  level |   ?    |
 |        |        |        |00010100|        |
0|10001000|01101010|10000000|00010100|11101110|0 <-- Shock?
0|10000001|01101010|10000000|00010100|01111110|0 <-- Vibrate?
0|10000100|01101010|10000000|00010100|11011110|0 <-- Blink?
0|10000010|01101010|10000000|01100100|10111110|0 <-- Beep?

Awwh yiss! Turns out the designers were nice enough to put the level in as an 8-bit integer. I've seen some people encode decimal numbers in strange ways... but this is pretty straight forward. That does raise some questions though! Just for the sake of discussion, I've randomly assigned the remote functions to the packets.

  • The remote had shock levels from 0 - 100. What happens if I transmit level 127? 255? Is the byte signed, unsigned? Is the MSB a flag, or just ignored?
  • It makes sense that Shock and Vibrate have a level... but what do blink and beep need a level for? Can I beep different notes, and make music?
  • One of the commands (perhaps beep) has a different level. Why? If I changed the value to something else, how would the collar behave?
  • (Aside, there is a possibility that the "level" field just happened to be 20 by chance. We are doing blackbox testing, remember!)

The rest of the fields?

Let's think for a moment, and see if we can figure out what the rest of the packet fields mean.

  • The device supports two channels so you can use the system with two dogs. Presumably, there should be a way to differentiate which dog gets the "correction".
  • It'd be nice to have some sort of "MAC address" so that I don't zap every dog in the park using Collar #1...
  • I only showed you the "good" messages. There's a chance noise causes a bit to flip. While 0x50 and 0x10 are only one bit off, that could mean a shock level of 80 as opposed to 16! Is there a checksum of some sort to make sure we don't spank the stuffing out of Sparky?
  • (And so obvious I nearly forgot it!) There should be a way to identify the command being sent!

Let's look at the packets again. I've sorted them to make life easier.

 |    0   |    1   |    2   |  level |   3    |
0|10000001|01101010|10000000|00010100|01111110|0 <-- Vibrate?
0|10000010|01101010|10000000|01100100|10111110|0 <-- Beep?
0|10000100|01101010|10000000|00010100|11011110|0 <-- Blink?
0|10001000|01101010|10000000|00010100|11101110|0 <-- Shock?
      ^^^^

If you look at the first field, you'll notice a little pattern. Looks like we found our "command" byte. I'm not terribly sure what the other four bits represent, but we can tack that down for later. It's not like they're changing. Does make you wonder though... What does 10001111 do? Vibrate, beep, blink, and shock?

Bytes^H^H^H^H^H Octets 1 and 2 don't seem to change. Perhaps this is a 16 bit system ID. Odd that we got 0x80 though. That's like going to the DMV and getting license plate ABC-123. Maybe the ID field is only 8 bits long, and 0x80 means something else. We won't really know without buying another system, or fuzz testing.

That accounts for all but one byte. Perhaps the last byte is our checksum. There are some great tools for bruteforcing checksums, like jboone did for his TPMS system... but I noticed a pattern. Look at that 0 walking from the MSB downwards. They didn't just reverse and invert the bits from the command byte, did they? Won't know until I try it with the collar on.

...as in, powered on. Not wearing. But I'm getting ahead of myself. First, let's update our block to decipher the messages.

Finishing the Receiver

It's electrifying!

Not too much to explain here. I put a (partially unneeded) low pass filter in, just because there were some nasty harmonics that might have interfered with reception. Do realize that this filter is before demodulation, so it's simply to remove interfering signals. A filter after demodulation would help tidy up the "square wave".

I also resampled my waveform, so that each "symbol" (meaning start, bit, and stop) was 20 samples long for performance reasons. It's a little less CPU intensive than 1003.3 samples per symbol. As Tim O'Shea noted, Python is great for prototyping, but not for performance.

The Polyphase Arbitrary Resampler is a nice block that lets me resample without too much thought to the signal processing. If your symbol length is 1003.3 and you want it to be 20, you simply divide the two, and put that in for the rate.

If you're wondering, you can't quite use the Clock Recovery MM in this instance, because there are symbols that were 1.5 bits long.

The DC Blocker is a neat little block which lets me not worry about the amplitude of the signal. It looks over a period of 80 samples (or in my case, four symbols), calculates the average amplitude, and removes it from the signal. The output looks something like this!

DC just causes problems. Except for flashlights.

Think for a moment about our incoming signal. It will always be greater than (or equal to) zero. In order to decide if the signal is a one or a zero, we must arbitrarily choose a threshold. Doing this, however, we run the risk of losing weaker transmissions! If I set my threshold to 0.1, I might lose signals that have an amplitude of 0.09 -- even though there's plenty of signal to still handle!

The DC Blocker solves this problem by automatically centering my signal around 0. It doesn't work very well in this case, since my signal has lots of 0 bits back to back. Notice how in the time between 400 and 600, the signal gets closer to zero? That could lead to a bit error!

I chose a sliding window of 80 samples because anything less was too short, and my signal wandered too close to zero. When you're doing reverse engineering like this, a some filters are "to taste". If the engineers had used something like Manchester encoding or data whitening, our data would have an amortized average closer to zero, and extend the range of the receiver. However, this would also require proper clock recovery, and make the device more expensive.

The Binary Slicer just converts my floating point signal into a byte, and then I look for packets in that stream with my custom Shock Collar Sink. Once a packet's found, you get a nice little message in the terminal!

10000100 01101010 10000000 00010100 11011110 
???:  1000
CMD: Blink?
ID?: 0x6a80
Lvl: 20
Checksum OK? True

Finally, heed the wise words of Balint Seeber. If you have multiple variables that have dependencies on each other, learn to love GNU Radio's Variable block! You can see mine along the left side, although the equations have already been evaluated. If I ever wanted to change a parameter, all the dependent ones would update automatically. (I also added an RF Gain slider, just so I wouldn't have to keep restarting my program if I wanted to tweak something.)

While testing, I temporarily replaced the Osmocom Source with the File and Throttle blocks. While you can certainly get away without the filters or DC Blocker, I wanted this to run live. I had to wait until tomorrow to see if it worked, though!

Building the Transmitter

Warning, hackery lies ahead...

So, I half got this part working. Unlike the receive side, which runs on a continuous stream of samples, the transmit side operates asynchronously. That's what the gray tabs and dotted lines mean. While I could have made a custom GUI or other interface, GNU Radio provides a simple means for asynchronous communication in the form of a socket. That's right... my shock collar is now on the network. (Being that I'm at DEFCON, it might have been wiser to bind to localhost...)

When the Socket PDU block receives a message, it passes it on to the Shock Collar Source, a block of my own design. I didn't have enough time to wrap it up in JSON, so it just looks for something like "shock, 20". If no level is provided, it defaults to 20. I could have also passed the "collar ID" field (bytes 1 and 2 above), but I was rushed for time, and simply hard coded it into the block.

While GNU Radio works very well with continuous streams, it doesn't work very well with bursts. Not wanting to try and rewrite existing code, I simply wrapped up the samples as a message, and passed it on to the PDU to Tagged Stream block. Unfortunately, it doesn't very much like being passed a message with more than 32k samples, so I had to interpolate later down the stream.

You can safely ignore the Rotator. That's just me trying to avoid the BladeRF's DC Spike. I rigged the radio up to transmit at 433.67 MHz, and shifted my baseband signal up 250 kHz (to 433.92 MHz). Meeh. It sorta worked.

Testing, one two three... Wait, is this thing loaded?

Now I know my receiver is fully functional, but I need to test the transmitter. I could have done this purely as a simulation. Just disable the osmocom blocks, and run the blue tab from the transmitter straight into the receiver. Time was running out though, and I needed to make sure the Osmocom Sink worked with the BladeRF. I plugged in my radio, hooked up an antenna, and sent "beep" over telnet.

I had just intended to confirm the BladeRF would transmit something--but much to my surprise, I heard the collar beeping in Russ' hand on the other side of the room. Thank goodness no one was wearing it, and I had matched up "beep" correctly! I suppose this is the inverse demo gods punishing me for rushing.

After that, it only worked about 10-20% of the time. I've had buffering issues with GNU Radio before, where it only transmits a portion of the samples I send, or loops back and retransmits something I had sent previously. This is partially hackery in GNU Radio's corner, and partially me doing something wrong. (The code base is getting MUCH better, though.)

About that checksum...

Checksums are supposed to help you verify the contents of your transmission didn't get corrupted. However, if you know anything about checksums, you'll realize that this "algorithm" doesn't check the rest of the contents of the packet. To illustrate this, let me toggle a bit in the packet.

 |  Cmd   |     Collar ID   |  Val   | Chksum |
0|10001000|01101010|10000000|00010100|11101110|0 <-- Shock?
0|10001000|01101010|10000000|01010100|11101110|0 <-- Shock?
                              ^ (Bit error)

// cmd       = 10001000
// inverted  = 01110111
// backwards = 11101110
// checksum  = 11101110, we're good!

I've highlighted the single bit that flipped. (Look back at the DC blocker animation, and imagine a burst of noise happened to make that bit look like a one to the receiver!) Although the received packet is erroneous, the command and checksum still match. How will the collar treat this?

A simple way to make this checksum slightly better is to sum all the bytes in the message together. If one bit changes anywhere in the message, you should be able to detect it. (Better algorithms exist, like CRC. My point is just to illustrate here!) Try it for yourself below!

 |  Cmd   |    Collar ID    |  Val   | Chksum |
 |  0x88  |  0x6a  |  0x80  |  0x14  |  0x86
0|10001000|01101010|10000000|00010100|10000110|0 <-- Transmitted Msg
0|10001000|01101010|10000000|01010100|10000110|0 <-- Received Msg
                              ^ (Bit error)

// tx_sum = (0x88 + 0x6a + 0x80 + 0x14)
// tx_sum = 390 % 256 == 134 (0x86)

// rx_sum = (0x88 + 0x6a + 0x80 + 0x54)
// rx_sum = 454 % 256 == 198
// chksum = 0x86 (134)
// 134 != 198, there was an error!

Does this mean that the "checksum" is completely useless? Probably not. It's likely that the receiver requires 20 or 30 duplicate messages in a row before it makes a decision. However, this tool will let us fuzz the device to properly answer that question.

Conclusions?

Well, I'm not releasing the code for a few reasons. First and foremost, because this was a really cool game for the wireless village! I don't want to burn it for next year. The goal of the Wireless Village is to learn! That being said, I feel completely safe describing the mechanics of all this, because it's still a sizeable task to develop the code. The state machine for outputting bits was trickier to write than I initially thought. Also, it seems that Zero's already spelled out several contests, and has yet to find anyone to solve them. (As a further confounding factor, I may not have told you the truth about which command is shock. >:D)

Second, I really don't want to be known as the guy that made the tool that lets you run around zapping dogs in the park. Or invented some new fad in Japan.

What about responsible disclosure? Should I have notified the manufacturers first? Maybe, but I'm not too concerned.

  • There's no way for the user to upgrade the firmware without some serious hackery. A lot of these devices use one time programmable uC's to save cost.
  • Who manufactures this again? There's about 30 clones on Amazon, likely all sold by different distributors.
  • Furthermore, I wouldn't be surprised if the design was stolen from the original manufacturer, and sold to everyone trying to make a quick buck. Security and ethics are not a primary concern for these people.
  • Yes, you can buy a CC1111 and make a leave behind dog shocker thing. However, this is no small task to undertake. Most people who have this level of knowledge use their skills for good, not evil. If they're willing to invest this much time, we have a bigger problem than our dogs getting shocked.
  • If you're that concerned about malicious actors, buy a different collar, and do your own research!

The point of writing all this was to show you some of the inner workings of reverse engineering sub-GHz wireless protocols. It's really not as hard as it looks, in some cases. If you're really interested in learning how to do this for yourself, take a look at my github for some more (and less controversial) ideas.

Thanks again to the Wireless Village for hosting a great event, and spreading knowledge to the masses. Maybe in the future, this will lead to more secure designs.

Piwik: Anonymized, respects DNT

@Landon1400
Copy link

This was very funny and informative. Thank you!

@spth
Copy link

spth commented Jul 17, 2018

I just noticed this after recently starting a similar reverse-engineering project:
https://github.com/spth/cockshock
It is about a simpler device though, that does not have adjustable shock levels (and it seems not having adjustable shock levels is a common complaint among users), and I don't have an SDR.

However, I also ordered a device that looks like the one you used, and intend to look into that one later.

Philipp

@spth
Copy link

spth commented Aug 7, 2018

I now got into a device similar to the one you used, and modified it to disable the 4 minute auto-shutdown timeout:

https://github.com/spth/cockshock2

Philipp

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment