When I last worked on this effort in October 2025, I had left off puzzled and frustrated that I had a decryption key and encrypted message traffic, but only one tantalizing successful decryption on hand. I eventually put the project aside for twelve months.
Returning with a fresh mind
My interest in the project returned when I realized I might be able to dump the firmware from the Scout itself. Using DuPont wires and a Raspberry Pi, I connected to the ESP8266 and extracted its four-megabyte flash image. This was satisfying but, for the immediate problem, something of a side quest. A raw embedded image (with few symbols, unfamiliar memory mappings, and none of the structure of a ‘normal’ application executable) proved much less approachable than the ForeFlight binary.
I became resigned to the fact that the ForeFlight app binary was a far more accessible and readable data source. But the hands-on act of firmware extraction rekindled my interest in the overall project and I returned to it with new enthusiasm.
I may write up the firmware-extraction process in a later blog post.
Decryption puzzle solved
The trick to understanding and solving the decryption problem was rooted in these facts:
- AES operates on 16-byte blocks
- The encrypted message payloads were 28 or 29 bytes long, not multiples of 16
- The decryption routine at
sub_100D8C194operates on the trailing 16 byte window first
The key information was rooted in that last item. The tail 16 bytes are decrypted first, and that same 16 bytes are mutated in-place from ciphertext to plaintext. Only then is the first 16 bytes decrypted. And the critical piece to understand is that the final few bytes of that first portion’s ciphertext were embedded in the tail’s 16 bytes. This broad technique has a name: ciphertext stealing.
What “ciphertext stealing” means here
The sender had 29 bytes of plaintext but AES only handles 16-byte blocks. To avoid padding:
- Encrypt block 1 (the first 16 plaintext bytes), creates
C1(16 bytes). - Save the first 13 bytes of
C1. - Paste the last 3 bytes of
C1into the start of the plaintext that will become block 2. - Encrypt block 2, now transmit all 16 bytes.
The receiver reverses this:
- Decrypt block 2 (the last 16 wire bytes), which reveals the stolen 3 bytes + trailing plaintext.
- Reassemble
C1from the leading 13 wire bytes + the 3 recovered bytes. - Decrypt
C1, yields first 16 plaintext bytes.
Total ciphertext on the wire equals total plaintext; no padding is added or removed. The two decryption steps are dependent (step 2 feeds step 3), not independent.
Decrypting Scout/Sentry GDL 90 encrypted traffic: a complete worked example
Step 0: The raw message as captured off the wire
7e 26 00 d7 64 6f 8c 55 68 a1 cf 1d 4d 4d 85 21 92 0b 4c e7 00 ff bd 95 df 78 27 78 5c 94 15 5f be eb 7e
35 bytes on the wire, including the 0x7e framing flags, the 0x26 0x00 message marker, and the 0xbe 0xeb CRC.
Step 1: Strip the framing flags
The 7e bytes at both ends are delimiters; they tell the parser “a message starts/ends here.” Remove them.
26 00 d7 64 6f 8c 55 68 a1 cf 1d 4d 4d 85 21 92 0b 4c e7 00 ff bd 95 df 78 27 78 5c 94 15 5f be eb
33 bytes remain.
This particular packet contains no escaped bytes. In the general case, the receiver must now remove each 0x7d escape byte and XOR the following byte with 0x20. The CRC is checked only after this unescaping step.
Step 2: Check the CRC (do this before decrypting)
The last 2 bytes are a CRC-16 computed over everything before them. Peel them off and set them aside.
body: 26 00 d7 64 6f 8c 55 68 a1 cf 1d 4d 4d 85 21 92 0b 4c e7 00 ff bd 95 df 78 27 78 5c 94 15 5fcrc: be eb
CRC computation
Init 0, polynomial 0x1021 from table at 0x1050D5960, but with unusual recurrence. GDL 90 calls this a CRC-CCITT, although its published recurrence differs from the CRC-16/XMODEM implementation found in many libraries. That distinction matters: using a generic XMODEM routine produces the wrong checksum. The lookup table is the standard CRC-16/CCITT table (256 entries, poly 0x1021), but the incoming byte is XORed after the table lookup rather than being folded into the lookup index:
for each byte b: crc = table[(crc >> 8) & 0xFF] ^ (crc << 8) ^ b
Compare with standard CRC-16/XMODEM, which folds the byte into the index before lookup:
crc = table[(crc >> 8) ^ b] ^ (crc << 8)
Same table, same polynomial, same init. The only difference is whether the byte enters before or after the table lookup. This produces a different checksum.
Step 3: Identify the header
26 00 d7 64 6f 8c 55 68 a1 cf 1d 4d 4d 85 21 92 0b 4c e7 00 ff bd 95 df 78 27 78 5c 94 15 5f│ │ ││ │ └── ciphertext starts here (29 bytes)│ └────── unknown envelope byte; observed as 0x00 or 0x01└────────── msgID 0x26 (proprietary; routes to the AES decrypt path)
0x26 is not a standard GDL 90 message ID, it is ForeFlight’s proprietary extension. In the binary it dispatches to handleGDL90Packet:atTime:, which invokes decryptPacket:withKey:. Labeled “encrypted envelope” here descriptively.
The second byte is never read by any code path in ForeFlight. In the original five samples it was always 0x00; in wider captures it also appears as 0x01. An alternating sequence flag is one possibility, but its purpose remains unknown.
Step 4: Split the ciphertext
AES operates on 16-byte blocks. The 29 byte ciphertext is clearly not a multiple of 16.
ciphertext (29 bytes):[ d7 64 6f 8c 55 68 a1 cf 1d 4d 4d 85 21 ][ 92 0b 4c e7 00 ff bd 95 df 78 27 78 5c 94 15 5f ]└──────── 13 bytes: leading fragment ──────┘└───────────── 16 bytes: one full block ────────────┘ (cannot be decrypted alone) (complete, can be decrypted)
Step 5: Decrypt the full block (the tail)
D( 92 0b 4c e7 00 ff bd 95 df 78 27 78 5c 94 15 5f )== 1a 1c 76 ee 80 03 41 53 41 31 38 38 20 20 06 0a ──────── ───────────────────────────────────── 3 bytes 13 bytes recovered trailing plaintext
The first 3 bytes of this output (1a 1c 76) are not plaintext! They are the last 3 bytes of the first ciphertext block that the sender placed here. The remaining 13 bytes (ee 80 03 41 53 41 31 38 38 20 20 06 0a) are genuine plaintext.
Why: the sender, before encrypting, took its full first ciphertext block C1 (16 bytes), split it, and pasted the tail 3 bytes into the second block’s plaintext. So decrypting the second block reveals those 3 bytes.
Step 6: Rebuild the first ciphertext block
C1 was 16 bytes. You have its first 13 (the leading fragment). The last 3 just came back out of step 5. Concatenate those two fragments:
C1 = [ d7 64 6f 8c 55 68 a1 cf 1d 4d 4d 85 21 ][ 1a 1c 76 ] └──────────── leading 13 bytes ──────────┘└─recovered─┘ == 16 bytes. Now a complete AES block.
Step 7: Decrypt the rebuilt first block
D( d7 64 6f 8c 55 68 a1 cf 1d 4d 4d 85 21 1a 1c 76 )== 14 00 ad 03 6b 21 e2 31 a9 05 09 0d 29 8a 0c bf
This yields 16 bytes of plaintext.
Step 8: Assemble the full plaintext
[ 14 00 ad 03 6b 21 e2 31 a9 05 09 0d 29 8a 0c bf ][ ee 80 03 41 53 41 31 38 38 20 20 06 0a ]└──────────────── first block (16 bytes) ───────────┘└─────────── trailing 13 bytes ────────────┘ == 29 bytes of plaintext
Step 9: Decode the plaintext as a GDL 90 Traffic Report
GDL 90 message 20 (Traffic Report) layout per §3.5.1. The 29-byte plaintext:
14 00 ad 03 6b 21 e2 31 a9 05 09 0d 29 8a 0c bf ee 80 03 41 53 41 31 38 38 20 20 06 0a
Field boundaries (byte positions 1-indexed per spec):
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
14 00 ad 03 6b 21 e2 31 a9 05 09 0d 29 8a 0c bf ee 80 03 41 53 41 31 38 38 20 20 06 0a
|| || |------| |------| |------| |---| || |------| || || |---------------------| || ||
| Byte(s) | Field | Hex | Decoded |
|---|---|---|---|
| 1 | Message ID | 14 | 20: Traffic Report |
| 2 | Alert / addr type | 00 | alert=0, addrType=0 (ICAO) |
| 3–5 | ICAO address | ad 03 6b | 0xAD036B |
| 6–8 | Latitude | 21 e2 31 | +47.64876° |
| 9–11 | Longitude | a9 05 09 | −122.31609° |
| 12–13 | Altitude + misc | 0d 29 | 4250 ft, misc=0x9 |
| 14 | NIC / NACp | 8a | NIC=8, NACp=10 |
| 15–16 | Groundspeed | 0c bf | 203 kt |
| 16–17 | Vertical rate | bf ee | −1152 fpm |
| 18 | Track | 80 | 180° (south) |
| 19 | Emitter category | 03 | Large (75,000 to 300,000 lb) |
| 20–27 | Callsign | 41 53 41 31 38 38 20 20 | ASA188·· |
| 28 | Priority / spare | 06 | no emergency; Scout extension marks byte 29 valid |
| 29 | Geometric-altitude data | 0a | +225 ft → geometric altitude 4,475 ft |
This corresponds with a large, fast, descending aircraft heading south through the Boeing Field / Duwamish corridor, consistent with an arrival into Seattle.
Byte 29 is a proprietary Scout extension rather than part of the published GDL 90 traffic-report format. Reverse engineering ForeFlight’s parser showed that bit 1 of byte 28 marks the extension as valid. ForeFlight interprets byte 29 as a signed pressure-to-geometric-altitude adjustment, encoded in 25-foot increments with zero reserved.
Working proof-of-concept Scout/Sentry monitor
I have a Python program that receives the UDP port 4000 Scout broadcasts, decrypts them, and writes them to stdout as well as optionally to a file for later work. In practice on the ground I’ve successfully received and decoded:
- Ownship reports
- Scout heartbeats
- Device messages
- GDL 90 traffic reports (0x14 / decimal 20)
12:19:54.408 TRAFFIC callsign=N456N addr=A58992 lat=47.700255 lon=-122.356260 alt_pressure_ft=1600 alt_geo_ft=1975 speed_kt=80 track_deg=329.06 vertical_fpm=256 airborne=yes nic=9 nacp=1012:19:54.413 TRAFFIC callsign=ASA594 addr=AD237C lat=47.652490 lon=-122.316027 alt_pressure_ft=3600 alt_geo_ft=4025 speed_kt=174 track_deg=180 vertical_fpm=-640 airborne=yes nic=8 nacp=1012:19:55.427 HEARTBEAT initialized=yes gps_valid=no utc_valid=no anonymous=yes device_time=unavailable uplink_count=0 traffic_count=0 extra=00000000000000000012:19:55.427 DEVICE model=Scout hardware_id=11 firmware=2.3.6 serial=329163165 secondary=absent12:19:56.452 OWN callsign=SELF addr=000000 position=unavailable alt_pressure_ft=0 speed_kt=0 track_deg=0 vertical_fpm=0 airborne=no nic=0 nacp=0
The traffic reports directly correlate with aircraft overhead (observed in FlightRadar24.com).
The Scout specifications indicate that it should also emit standard GDL 90 Uplink Data messages (0x07, decimal 7), whose 432-byte UAT payload can carry FIS-B weather products. I have not yet received one at ground level from my location, so that remains untested, and gives me an excellent excuse for a future aerial capture.
For now, though, the original puzzle is solved: the supposedly proprietary traffic packets are ordinary GDL 90 reports wrapped in a small encrypted envelope, and a discontinued receiver that had been sitting in my drawer is once again producing useful data.







