NDIAS Auto/IoT CTF: Keyfob Challenges Writeup
This week I have a very different type of CTF challenge writeup: keyfob. I attended NDIAS Auto/IoT CTF over the last weekend, and ended up solving some keyfob/signals related challenges. These type of challenges are quite rate in CTF, in fact it was the first time I saw it in an online CTF since the start of this year. This was a rare opportunity I couldn’t miss, so I had to take a break from pwn challenges this week.
Keyfob was one of the categories in this CTF, I ended up solving 6 out of 8 keyfob challenges. I will publish all challenge writeups here instead of separate entries. They are pretty straightforward, so I believe one post should be enough to cover the 6 challenges I solved.
Challenge 1: Parking Lot Whisper
These challenges come with a radio recording of the spectrum of keyfob signals that require understanding of radio signals and how to process them. Keyfobs generally use On OFF keying (OOK) to transmit data, so my first instinct was to look at the spectrum and see if that would apply here. Looking at the waterfall plot of the capture, I found that there were 6 bursts and zoomed into one of them:
Looking at the spectrum, we can see some wait periods, on and off periods of varying durations. This is a very good example of on-off keying modulation. On periods represent one, and off periods represent zero. Shortes period of on or off is probably the symbol duration. Challenge asks us t find Centre frequency, modulation type and symbol period. We can from the spectrum that bursts are centred, and we are given that capture is taken at 433.92 mhz, so the centre frequency of bursts are 433920000 hz. And modulation type is OOK. To find the symbol duration/period I used Universal Radio Hacker (URH) and let it auto find it for me:
Just by loading the file it auto detected some parameters like modulation type is ASK. OOK is an simple form of Amplitude Shift Keying (ASK) where we have only two levels on and off. It also predicts that symbol length is 1000 samples, given the 2Msps sampling rate this gives us:
1000 / 2e6 = 500 microseconds
To confirm if it is right, zoom into one of the bursts and find the shortest looking on or off duration and select that region:
If you set the sampling rate of the capture in details properly, it should give you the rough duration and number of samples. Looking at the results, we can see roughly 1000 samples and 500 microseconds duration. And with that we got the flag: flag{433920000_OOK_500}
Challenge 2: Read the Simple Fob
This one is the continuation of the first challene with the same capture file. We are given some clear hints in the challenge description:
1
2
3
4
The file capture_c1c2.bin used in the previous challenge contains a Falcon X1 keyfob signal.
The Unlock frame of this keyfob is Manchester-encoded.
Locate a complete Unlock frame in the IQ data, perform Manchester decoding, and analyze the decoded data to recover the Device ID.
The Device ID is defined as the 3 bytes immediately following the sync word 0xD5.
It tells us that it uses Manchester encoding, there is a sync word 0xD5, and 3 bytes of ID. URH is great at decoding these signals, so I continued from there. Looking back at the full capture in URH, we can see some short noise looking like parts. I simply deleted them to work on the good looking 6 bursts. And then played with decoding parameters in Analysis tab until I got a result with 0xD5:
We can see repeating bytes that we call preamble, 0xD5 byte is right after that and some repeating data that is probably the ID and some extra stuff:
1
2
PREAMBLE SYNC ID REST
aaaa d5 7a21cc 2001375a
With this simple analysis, we got the flag: flag{7A21CC}
Challenge 3: Next Counter
This challenge comes with a new capture file. And this is the challenge description provided:
Falcon X1 keyfob transmissions from a different device were captured multiple times. Analyze the frames, identify the pattern exhibited by this keyfob, and predict the frame that will be transmitted during the next Unlock action.
This CTF was the first time I actually properly used URH to decode some signals. After being impressed by its abilities in the first two challenges, I decided to keep going with URH to decode these OOK modulated signals. Again I followed the same steps from the other challenges: load the signal, clean out noisy looking parts and apply manchester decoding:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
aaaa d5 9c4e2b 2001 37 5a
aaaa d5 9c4e2b 2001 37 5a
aaaa d5 9c4e2b 2001 37 5a
aaaa d5 9c4e2b 2001 38 5a
aaaa d5 9c4e2b 2001 38 5a
aaaa d5 9c4e2b 2001 38 5a
aaaa d5 9c4e2b 2001 39 5a
aaaa d5 9c4e2b 2001 39 5a
aaaa d5 9c4e2b 2001 39 5a
aaaa d5 9c4e2b 2001 3a 5a
aaaa d5 9c4e2b 2001 3a 5a
aaaa d5 9c4e2b 2001 3a 5a
aaaa d5 9c4e2b 2001 3b 5a
aaaa d5 9c4e2b 2001 3b 5a
aaaa d5 9c4e2b 2001 3b 5a
Looking at the column just before final byte 0x5A, we can clearly see a counter is increasing by one. Challenge asks for the next open frame which requires us to use next counter. Also looking at the data, we can see that the counter is the only byte that is changing. This confirms that there is no CRC or some form of checksum that we would need to recalculate, so we just increase the counter and we have the next packet: AAAAD59C4E2B20013C5A which is the flag of this challenge.
Challenge 4: Predict Next UNLOCK
This challenge comes with a new capture again. Description:
Multiple Unlock transmissions from another Falcon X1 keyfob were captured. Analysis of the frames shows that, in addition to the Counter, there is another value that changes between transmissions. Based on the observed pattern, predict the unique valid Unlock frame that will be transmitted next by this keyfob.
It sounds similar to the third challenge with some more changes. I followed the same decoding steps and I got this data out of it:
1
2
3
4
5
6
7
8
9
10
11
aaaa d5 b84f62 2002 04 c18b 5a
aaaa d5 b84f62 2002 04 c18b 5a
aaaa d5 b84f62 2002 04 c18b 5a
aaaa d5 b84f62 2002 05 d4c2 5a
aaaa d5 b84f62 2002 05 d4c2 5a
aaaa d5 b84f62 2002 06 e7f9 5a
aaaa d5 b84f62 2002 06 e7f9 5a
aaaa d5 b84f62 2002 06 e7f9 5a
aaaa d5 b84f62 2002 07 fb30 5a
aaaa d5 b84f62 2002 07 fb30 5a
aaaa d5 b84f62 2002 07 fb30 5a
As the challenge suggests, in addition to the counter byte there are two more bytes changing this time. To be able to create next packet, we have to know how these values are calculated so we can calculate the next one. After a bit of struggling and trying bit shifts etc. I found that they were increasing by a fixed amount:
1
2
3
d4c2 - c18b = 1337 (+0x1337)
e7f9 - d4c2 = 1337 (+0x1337)
fb30 - e7f9 = 1337 (+0x1337)
So to create next packet, we need to use next counter 08, and the next two bytes counter fb30 + e7f9 = 0e67. With that we get the next packet and flag: AAAAD5B84F622002080E675A
Challenge 5: Classic KeeLoq Garage: Find the Key
First 4 challenge were classified as easy, now we are moving into medium level challenges. As the title of the challenge suggests, this one involves KeeLoq. KeeLoq is a hardware based block cipher to encrypt rolling codes. Classic indicates that it is the original KeeLoq encryption, not the recent AES based one. Challenge description:
An RF capture from a garage remote was obtained, and an installer’s note was also recovered at the scene. It was determined that this system uses a classic rolling code scheme. Analyze the RF capture and the note, and recover the 64-bit DeviceKey used by this remote. The synchronization word is the same as in the previous challenges.
As it is mentioned in the description, we are also provided a note along with the new capture file:
1
2
3
4
5
6
7
8
9
<<NDIAS-GARAGE Install Note>>
Model: KG-370
Remote SN: ???
Button map: 0x01 = OPEN
Fixed part: SERIAL(32) || BTN(8)
Legacy derive: K = SEED || (SEED XOR SERIAL)
Seed: 6D3A91C4
Disc bits: lower 10 bits
Learn window: 16
We are given most of the details of the key logic in this note actually. But, this challenge actually threw me off a bit initially. Notice how the note mentione 0x01 is for opening, and now look at the received data after decoding it in URH:
1
2
3
4
5
6
7
8
9
10
11
aaaa d5 913b00d7 01d4a2b700
aaaa d5 913b00d7 01d4a2b700
aaaa d5 913b00d7 01d4a2b700
aaaa d5 0020d1fb 01d4a2b700
aaaa d5 0020d1fb 01d4a2b700
aaaa d5 80ed145b 01d4a2b700
aaaa d5 80ed145b 01d4a2b700
aaaa d5 80ed145b 01d4a2b700
aaaa d5 314c03e4 01d4a2b700
aaaa d5 314c03e4 01d4a2b700
aaaa d5 314c03e4 01d4a2b700
A bit of reading about the protocol, and looking at the received data, we can definitely say that the part after the sync word is the encrypted block - 32bits exactly. Reason is simple, that part changes and rest of the packet doesn’t. So essentially we get two parts: encrypted part and fixed part. Now the confusing part, note was saying Fixed part: SERIAL(32) || BTN(8) taking || as concatenation, we need to concatenate button info to serial number. Looking at the 0x01 in the fixed part, I assumed that was button = open, and it wasn’t
After generating keys based on my assumption and failing with each submission, it finally occured to me that 0x01 was not the button, but 0x00 at the end was the button code! So as it was shown on the note, button code was concatenated to the end which gives us:
01d4a2b7 = SN
And then to find the key:
K = SEED || (SEED XOR SERIAL)
K = 6D3A91C4 || (6D3A91C4 XOR 01d4a2b7)
K = 6D3A91C46CEE3373
And this is the flag for this challenge. My wrong assumption at the beginning led to me losing some time but in the end we got the key. This key will be used in the next challenge.
Challenge 6: Classic KeeLoq Garage: Next HOP
We are continuing with the same file and the note from the previous challenge. Now the task is to generate next OPEN signal frame:
1
2
3
Using the DeviceKey recovered in Find the Key, analyze the rolling code used by this garage remote.
The Hop field is encrypted using the standard KeeLoq algorithm. Predict the next valid Open-signal frame.
The Hop field is a 32-bit Encrypted Hop, and the counter is 16 bits.
In terms of what we need to do, the task is simple: use the key from previous challenge and encrypt a new frame. But how do we encrypt it? Answer is, I don’t really know :D. At this point, I decided to use Claude to implement the encryption logic to save a bit of time.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
#!/usr/bin/env python3
"""
KeeLoq Decoder/Encoder
Device info:
Seed : 0x6D3A91C4
Serial : 0x01D4A2B7
Key : 0x6D3A91C46CEE3373 (SEED || SEED^SERIAL)
Disc : 0x2B7 (lower 10 bits of serial)
BTN : 0x01 = OPEN
"""
# ── KeeLoq core ──────────────────────────────────────────────────────────────
NLF_LUT = 0x3A5C742E # 32-entry non-linear function lookup table
def nlf(x):
"""KeeLoq Non-Linear Function using taps at bits 1,9,20,26,31."""
b0 = (x >> 1) & 1
b1 = (x >> 9) & 1
b2 = (x >> 20) & 1
b3 = (x >> 26) & 1
b4 = (x >> 31) & 1
idx = b0 | (b1 << 1) | (b2 << 2) | (b3 << 3) | (b4 << 4)
return (NLF_LUT >> idx) & 1
def keeloq_encrypt(pt, key):
"""Encrypt a 32-bit plaintext with a 64-bit key (528 rounds)."""
x = pt
for i in range(528):
kb = (key >> (i % 64)) & 1
b = (x & 1) ^ ((x >> 16) & 1) ^ nlf(x) ^ kb
x = (x >> 1) | (b << 31)
return x
def keeloq_decrypt(ct, key):
"""Decrypt a 32-bit ciphertext with a 64-bit key (528 rounds reversed)."""
x = ct
for i in range(527, -1, -1):
kb = (key >> (i % 64)) & 1
tmp = (x << 1) & 0xFFFFFFFF # reconstruct old state (bit0 unknown, nlf ignores it)
nlf_val = nlf(tmp)
b0 = ((x >> 31) & 1) ^ ((x >> 15) & 1) ^ nlf_val ^ kb
x = ((x << 1) & 0xFFFFFFFF) | b0
return x
# ── Key derivation ────────────────────────────────────────────────────────────
def derive_key(seed, serial):
"""Legacy KeeLoq key derivation: K = SEED || (SEED XOR SERIAL)."""
return ((seed & 0xFFFFFFFF) << 32) | ((seed ^ serial) & 0xFFFFFFFF)
# ── Plaintext layout ──────────────────────────────────────────────────────────
#
# [31:22] discriminator (10 bits) = lower 10 bits of serial ← MSB side
# [21:8] zeros / status
# [7:0] counter (low byte, increments per press)
#
# Fixed part layout (40-bit, transmitted in plaintext):
# [39:8] serial (32 bits)
# [7:0] button code (8 bits) 0x01 = OPEN
def parse_plaintext(pt, serial):
disc = (pt >> 22) & 0x3FF
counter = pt & 0xFF
disc_exp = serial & 0x3FF
disc_ok = disc == disc_exp
return {
"raw": hex(pt),
"disc": hex(disc),
"disc_ok": disc_ok,
"counter": hex(counter),
}
def build_plaintext(serial, counter, btn_in_hopping=0x0):
"""
Build the 32-bit hopping code plaintext.
disc = lower 10 bits of serial → placed at [31:22]
btn = button nibble → placed at [11:8] (optional, 0 if not used)
counter = press counter → placed at [7:0]
"""
disc = (serial & 0x3FF) << 22
btn = (btn_in_hopping & 0xF) << 8
ctr = counter & 0xFF
return disc | btn | ctr
# ── High-level helpers ────────────────────────────────────────────────────────
def decode_frame(hopping_ct, fixed_hex, seed, serial):
"""Decode a full KeeLoq frame."""
key = derive_key(seed, serial)
pt = keeloq_decrypt(hopping_ct, key)
parsed = parse_plaintext(pt, serial)
btn = int(fixed_hex[-2:], 16)
return {
"hopping_ct": f"{hopping_ct:08x}",
"plaintext": parsed,
"fixed_btn": f"0x{btn:02x}",
"btn_is_open": btn == 0x01,
}
def encode_frame(seed, serial, counter, btn_fixed=0x01, btn_hopping=0x0):
"""Encode the next KeeLoq OPEN frame."""
key = derive_key(seed, serial)
pt = build_plaintext(serial, counter, btn_hopping)
ct = keeloq_encrypt(pt, key)
fixed = f"{serial:08x}{btn_fixed:02x}"
return {
"plaintext": f"{pt:08x}",
"hopping_ct": f"{ct:08x}",
"fixed": fixed,
"full_frame": f"{ct:08x} {fixed}",
}
def encode_frame2(seed, serial, pt, fixed):
"""Encode the next KeeLoq OPEN frame."""
key = derive_key(seed, serial)
ct = keeloq_encrypt(pt, key)
return {
"plaintext": f"{pt:08x}",
"hopping_ct": f"{ct:08x}",
"fixed": fixed,
"full_frame": f"{ct:08x} {fixed}",
}
# ── Main ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
SEED = 0x6D3A91C4
SERIAL = 0x01D4A2B7
KEY = derive_key(SEED, SERIAL)
print("=" * 60)
print("KeeLoq CTF Decoder")
print("=" * 60)
print(f" Seed : {SEED:#010x}")
print(f" Serial : {SERIAL:#010x}")
print(f" Key : {KEY:#018x}")
print(f" Disc : {SERIAL & 0x3FF:#05x}")
print()
# ── Decode known captures ────────────────────────────────────
captures = [
(0x913b00d7, "01d4a2b700"),
(0x0020d1fb, "01d4a2b700"),
(0x80ed145b, "01d4a2b700"),
(0x314c03e4, "01d4a2b700"),
(0x65317adf, "01d4a2b700"),
]
print("── Captured frames ──────────────────────────────────────")
for ct, fixed in captures:
r = decode_frame(ct, fixed, SEED, SERIAL)
disc_sym = "✓" if r["plaintext"]["disc_ok"] else "✗"
print(f" CT={r['hopping_ct']} PT={r['plaintext']['raw']}"
f" DISC={disc_sym} CNT={r['plaintext']['counter']}"
f" BTN_fixed={r['fixed_btn']}")
print()
# ── Predict next frames ──────────────────────────────────────
print("── Next predicted OPEN frames ───────────────────────────")
r = encode_frame2(SEED, SERIAL, pt=0xadc40044, fixed = '01d4a2b701')
print(f" PT : {r['plaintext']}")
print(f" Full frame : {r['full_frame']}")
I got Claude to write to code, but I had to do some reversing to understand the frame logic. Now I used this code to decode the encrypted frames we received:
1
2
3
4
CT=913b00d7 PT=0xadc40040 DISC=✓ CNT=0x40 BTN_fixed=0x00
CT=0020d1fb PT=0xadc40041 DISC=✓ CNT=0x41 BTN_fixed=0x00
CT=80ed145b PT=0xadc40042 DISC=✓ CNT=0x42 BTN_fixed=0x00
CT=314c03e4 PT=0xadc40043 DISC=✓ CNT=0x43 BTN_fixed=0x00
CT is the cypher text - encrypted frames we received in the decoded capture file. PT is the plaintext - decrypted frames. DISC is just a check to see if the decrypted frames contain the expected 10 bits discriminator. This gives us roughly this layout we can use to generate new packets:
1
2
3
4
5
6
7
8
9
Plaintext layout
[31:22] discriminator (10 bits) = lower 10 bits of serial ← MSB side
[21:8] zeros / status
[7:0] counter (low byte, increments per press)
Fixed part layout (40-bit, transmitted in plaintext):
[39:8] serial (32 bits)
[7:0] button code (8 bits) 0x01 = OPEN
While trying to solve the 5th challenge, I had issues figuring out where the button info were going. Since I confirmed the serial part, we can safely say button state is the last byte, so fixed bytes - the part that doesn’t go through encryption - should be 01d4a2b701.
Next question I had was should button state be part of the encrypted part? Answer was no. In previous packets we had button state = 0x00, but none of the decoded bytes were 0x00! So it indicates button state wasn’t part of the encrypted portion. With this logic we end up with 0xadc40044 plain text where we increased the counter byte and kept the rest same.
And now we decided on what the frame is carrying, I just used the script to encrypt the frame and get the next hop packet:
1
2
PT : adc40044
Full frame : 65317adf 01d4a2b701
Final Notes
I didn’t have a chance to look at the final two challenges of keyfob category unfortunately due to time constraints. This CTF had another signals related challenge. I tried to solve that one and got very close to result but couldn’t. I might do another writeup for that challenge if I can find some time, it deserves its own writeup.
I personally love signals and radio related challenges, decoding packets, analyzing them and extracting information out of them. I wish we get more CTFs like this one. Thanks to keyfob challenges, I finally get to play with URH first time. It looks like it is a great tool for such signals. I will finish this writeup here, I don’t think I will update this for the other two keyfob challenges if I ever go back to them. See you on the next one and as always, keep learning!



