🌐 English version: English

系列第二篇,也是真正開始手刻 LoRa 解調器的一篇。上一題〈Channel Hop〉只是讀已經解好的封包日誌,但這題開始,拿到的是原始的 IQ 訊號,什麼都得自己來

然後這支解調器會從這篇一路陪我走到系列最後,所以這篇寫得比較細,把 LoRa 解調的骨架講清楚,後面幾篇就只講各自的變化

系列其他篇:Isodata Seven、Subsidy Claim、Two Voices、No Name on the Door、Hopscotch

題目在講什麼

題目畫面

第二次衛星通過,拖拉機這次收下了地圖、噴藥機也出動了。但領頭的 Johnripper 把圖上每一塊綠色的東西都標成「死亡」、並用最大劑量施肥。Babb 一小時前才走過田,玉米好端端的。

共用 SDR 這次錄下了 AgriLink 頻率上的原始訊號。AgriLink 說這次通過只有一次傳輸,但 SDR 不這麼認為。而且自昨晚起,AgriLink 傳頻道時不帶名稱(省空中時間),由桅杆依 band_order.conf 排序——而這個檔案在 02:13 被一個沒人記得開過的遠端 session 改寫過。

目標:找出 AgriLink 真正傳了什麼。給的檔案有約 200 MB 的 .sigmf-data(ci8、2 MS/s、434.3 MHz)、band order 設定檔、以及平板顯示的真彩色圖

Flag 格式 GAIANSPACE{...}。

LoRa 解調器的骨架

訊號是 LoRa,但筆記裡的參數(BW250k)是幌子,實際量出來是 SF7、BW500k。沒有現成的解碼器,所以自己寫一個

先說 LoRa 長什麼樣子,LoRa 當中的每個符號(symbol)是一段頻率由低掃到高的音調,叫做上掃頻(up-chirp),如果把它畫在頻譜圖上就是一條斜線,一個 burst 的頻譜圖長這樣(縱軸頻率、橫軸時間):

一個 LoRa burst 的頻譜圖,可以看到 chirp 的斜紋

那資料是怎麼藏進去的?每個符號都從不同的頻率「起跳」,這個起跳的位置就是這個符號代表的數字(0~127,SF7 共 128 種),所以解調的核心,就是量出每個符號「從哪個頻率起跳」

量法叫 dechirp(解掃頻),也就是把收到的上掃頻,乘上一段方向相反的下掃頻(down-chirp),一個往上、一個往下,斜率剛好抵銷,相乘後就變成一個固定頻率的音調,而這個音調落在哪個 FFT bin,就直接對應到符號的起跳位置。

下圖把這件事畫出來(藍=收到的上掃頻、灰=下掃頻參考、紅=相乘後的穩定音調):

dechirp 把一個符號變成一個 FFT 峰值,再把峰值經過 Gray/解交織/白化還原成位元組

整條流水線(這套骨架後面每一篇都會用到):

  1. 先對齊(同步) 用前導碼的上掃頻和後面的下掃頻各做一次 FFT,兩者的峰值可以同時解出時序(封包從第幾個樣本開始)與載波頻率偏移(CFO,收發雙方晶振不準造成的頻率差)。詳見下面的「半個符號的坑」。
  2. 逐符號 dechirp 取峰值 bin。 這裡有個實作關鍵:要用 2 MS/s 的原始取樣率(每符號 512 點)做,把折疊到 v 與 v−128 的兩段能量相加,再用拋物線內插取峰值。先降到 500 kS/s 再做會損失邊緣能量、出錯率高。
  3. 符號值 = 峰值 bin − 前導碼 bin − 1(扣掉前導碼的參考起點)。
  4. 把符號值翻成位元組:Gray 解碼 → 解交織 → 取資料位元 → 組位元組 → 與白化序列 XOR。怎麼知道每一步的方向(高低位、順序)對不對?用已知明文驗證:這批封包都以 AL(0x41 0x4c)開頭,而且解對時每個碼字的奇偶檢查都會是 0——對不上就代表某一步的慣例反了。

半個符號的坑(第 1 步的細節)

第 1 步同步時有一個很容易踩的雷:封包的起點會算出兩個都說得通的答案,公式本身分不出哪個才對。

為什麼會這樣?dechirp 後取的是 FFT 的峰值 bin,而 FFT 的 bin 是環狀的(127 的下一格會繞回 0)

這個「繞一圈」的特性,會讓「封包從第幾個樣本開始」算出來剛好有兩個候選起點,兩個差了正好半個符號(SF7 是 64 個 bin),而且兩個套進公式都對得上前導碼,所以沒辦法只靠公式二選一。

解法很土但有效:

兩個候選都試著解一次,看哪一個的 FFT 峰值又高又乾淨就選哪個。 對齊正確時,每個符號的能量集中在單一 bin,峰值又高又尖;只要差了半個符號,符號能量就會跨到相鄰符號、分裂成兩個矮峰,一眼就能分辨:

對齊正確時只有一個高而尖的峰;差半個符號時能量分裂成兩個矮峰,選峰值最強的那個候選

兩個發射源,一強一弱

解完 781 個 burst 後,真相浮現:這條頻率上其實有兩個發射源。

  • 弱信號(641 個):5 頻道 × 128 列,完整的一張圖塊,這邊就是真正的衛星資料
  • 強信號(139 個):強得多,但只重送了 ch0 和 ch3 的部分列,而且內容是弱信號的竄改版

這就是一次傳輸底下的謊言,當中有一個真的衛星以及另一個蓋過它的偽造發射源

桅杆收到的是比較強的那個,所以用到了被改過的資料,這邊如果把同一個(頻道, 列)的強弱兩版相減,ch3 的差異竟然拼出三行文字:

強信號減去弱信號,ch3 的差異浮現出隱藏文字(圖的後兩格)

把最後一行讀出來

強信號最初有幾列因為解調失敗而缺,文字呈現橫向斷線,這邊修正半個符號的歧義、補齊所有列之後,字形才完整,最後一行字元只有 5 像素寬,其實有點糊,我用三次方插值放大再逐格確認:

最後一行放大逐字確認

讀出 HR6TNM

Flag

1
GAIANSPACE{TRUE-NDVI-HR6TNM}

旗標名稱 TRUE-NDVI 也呼應了題目,被偽造者動手腳的 ch0、ch3 剛好是桅杆當成紅光與短波紅外線的頻道,而紅光正是算 NDVI(植被指數)的關鍵

這就是為什麼拖拉機把健康的玉米判成死亡

心得

  1. 「一次傳輸」可能有兩個發射源。 強的會壓過弱的(capture effect),只有看 SDR 原始 IQ 才分得出來;日誌只會顯示被接收的那一個。
  2. LoRa 解調最難的在同步。 CFO 與時序在上/下掃頻裡互相耦合,要用兩者的差與和分離,而且有半個符號的歧義。這套骨架一旦寫對,後面就是不斷地在它上面加功能。
  3. 自己實作時用「已知明文」逐一驗證每個慣例。 這題是靠 AL 開頭和標頭 nibble 來確認 Gray/解交織/位元組順序的方向,比憑記憶對照 LoRa 規格快太多。
  4. 訊息藏在「兩份雜訊的差」裡。 單看任何一版都看不出竄改,相減才現形——這招在後面的〈Isodata Seven〉會用另一種形式再出現。