Post

Understanding Verified Boot (Part 2): Building a Verify Gate with U-Boot FIT

How to turn the verified-boot theory into a working verify gate: where the chain can start on a given chip, what gets signed, how the verifier gets its key, and how U-Boot checks it at boot — using U-Boot FIT on BeagleBone Black as the worked example.

Understanding Verified Boot (Part 2): Building a Verify Gate with U-Boot FIT

Part 1 dựng cơ chế verified boot ở dạng lý thuyết. Phần này đi từ lý thuyết xuống tay: một cổng verify được dựng bằng những gì, và nó kiểm tra theo thứ tự nào lúc boot. Mỗi mục bắt đầu từ một câu hỏi mà bất kỳ ai cầm một board mới cũng sẽ gặp, và câu trả lời của mục này chính là câu hỏi của mục kế tiếp. BeagleBone Black (AM335x, bản GP) là board thật dùng để trả lời thử từng câu — nó là ví dụ, không phải chủ đề.

Lưu ý trước về phạm vi: tên công cụ trong bài (FIT, mkimage, control DTB) là của U-Boot. Chip khác, hãng khác có thể dùng công cụ và định dạng khác, nhưng câu hỏi thì không đổi. Những chỗ chỉ đúng cho BBB được đánh dấu bằng > **BBB**:; còn lại là điều áp dụng được cho board khác. Ngoài ra bài không nhắm dựng một hệ thống secure boot hoàn chỉnh — trên bản GP điều đó không khả thi, và mục 1 sẽ nói rõ tại sao.

Tài liệu gốc: Verified Boot — U-Boot documentation

Repo: haidoan2098/arm-secure-boot


1. Where Does the Chain Start on Your Chip?

Part 1 kết luận rằng chuỗi verify phải neo vào một điểm không cần verify nữa. Câu hỏi đầu tiên khi cầm một chip mới là: chip này có điểm neo đó không?

Điểm neo chỉ đáng tin khi vùng chứa nó có một tính chất: quyền ghi đã đóng vĩnh viễn, không ai — kể cả kẻ tấn công lẫn chủ board — ghi lại được. Thứ thường đóng vai đó là eFuse/OTP: ghi (blow) một lần lúc sản xuất, rồi đóng lại mãi mãi. Nếu Boot ROM đọc được key (hoặc hash của key) từ vùng này và có code verify tầng đầu tiên, chip có root of trust thật. Nếu không, mọi tầng đều nằm trên loại lưu trữ ghi đè được (thẻ SD, eMMC, flash), và chưa có gì để neo vào.

Vậy trước khi làm bất cứ thứ gì, cần đọc reference manual của SoC, phần security/boot, để trả lời ba câu:

  • Boot ROM có khả năng verify tầng nó nạp không, hay chỉ nạp rồi nhảy?
  • Nếu có, key hoặc hash key nằm ở đâu, và vùng đó có thật sự ghi-một-lần không?
  • Chip có nhiều biến thể silicon không, và board của mình là biến thể nào?

BBB: AM335x có hai biến thể. HS ghi một chiều public key (hoặc hash của nó) vào eFuse lúc làm chip, Boot ROM verify được MLO — root of trust phần cứng đúng nghĩa. GP — bản BBB đang dùng — eFuse trống và Boot ROM không có khả năng verify; không phải “có mà chưa bật”. MLO, u-boot.img, kernel đều là file trên FAT boot partition của thẻ SD, nơi cắm vào máy tính là ghi lại được bất kể file nào.

Vậy nếu chip không có điểm neo thì sao? Không có cách nào tạo ra root of trust bằng phần mềm — chỉ có thể chọn một tầng và giả định nó đáng tin, đồng thời biết rõ đó là giả định. Trên GP, MLO và U-Boot giống nhau hoàn toàn về vùng chứa nên không tầng nào xứng đáng hơn tầng nào; bài này chọn U-Boot, đơn giản vì đó là tầng vốn đã có việc phải làm (nạp kernel), đủ để dựng cơ chế lên và học từ đó.

flowchart LR
    ROM["Boot ROM"] -->|"no check"| MLO["MLO (SPL)"]
    MLO -->|"no check"| UBOOT["U-Boot"]
    UBOOT ==>|"verify OK"| KERNEL["Linux Kernel"]
    UBOOT -.->|"verify FAIL"| HALT(["Boot halted"])

Hai chặng đầu nạp thẳng rồi nhảy vào, không hỏi gì; chỉ chặng cuối có cổng. Kéo theo một hệ quả dễ vẽ sai: Boot halted chỉ đến từ đúng một chỗ. ROM và MLO không bao giờ dừng vì lý do xác thực, vì chúng không xác thực gì.

Từ đây câu hỏi tự nhiên là: cổng đó verify cái gì?


2. What Exactly Gets Signed?

Đầu vào của bài là bốn nhóm file, chưa qua sign:

FileLấy từ đâuMô tả
zImageYocto build rakernel nén
am335x-boneblack-kernel.dtbYocto build radevice tree đi kèm zImage
u-boot-nodtb.binthư mục build U-BootU-Boot chưa gắn device tree
am335x-boneblack-uboot.dtb + 9 .dtb khácthư mục build U-Bootdevice tree cho từng board U-Boot hỗ trợ

BBB: Yocto và U-Boot đều build ra file tên trùng nhau am335x-boneblack.dtb, nội dung khác nhau. Từ đây gọi chúng là am335x-boneblack-kernel.dtb và am335x-boneblack-uboot.dtb.

2.1. Why Not Just Sign the Two Files?

Kernel và device tree phải đi thành một cặp cố định. Thử sign riêng zImage và sign riêng .dtb: mỗi file đều có signature hợp lệ. Nhưng dù root of trust chuẩn đến đâu, signature chỉ xác nhận được một điều — file này do đúng người giữ private key sign — chứ không hỏi hai file này có thuộc về nhau không.

Nên kẻ tấn công không cần giả mạo signature, chỉ cần dùng hàng thật ở sai chỗ. Mục tiêu của hắn thường không phải làm hệ thống crash, mà là làm nó chạy ở một trạng thái bạn chưa từng cho phép trong khi mọi kiểm tra vẫn báo hợp lệ.

Ví dụ hai file đều do chính bạn sign, nhưng ở hai thời điểm khác nhau: zImage của bản phát hành mới ghép với .dtb của bản cũ. Device tree quyết định kernel bật những phần cứng nào và nhìn hệ thống ra sao, nên bản cũ có thể còn bật một cổng debug hoặc thiếu một thiết lập mà bản mới đã bịt. Hệ thống vẫn boot bình thường, nhưng lỗ hổng bạn tưởng đã vá đã mở lại. Cả hai signature vẫn pass, mà cặp ghép ra chưa từng được bạn cho phép. Hắn chỉ cần ghi được lên nơi chứa file, không cần biết private key.

Vấn đề nằm ở chỗ RSA sign một khối byte cụ thể, không sign được “một cặp file rời”. Muốn sign cả cặp như một đơn vị thì phải có sẵn một khối byte duy nhất đại diện cho cả cặp, trước khi RSA vào việc.

Đó là việc của một container. Container không phải cơ chế bảo mật, chỉ là quy ước đóng gói: gộp nhiều payload cùng một bản mô tả ghi rõ mảnh nào đi với mảnh nào. Cặp kernel + dtb không còn là quan hệ ngầm giữa hai lệnh nạp riêng, mà là một node cụ thể nằm cùng chỗ với dữ liệu.

U-Boot dùng container tên FIT (Flattened Image Tree). Bản mô tả của nó viết bằng cú pháp device tree chỉ vì U-Boot đã có sẵn code đọc device tree cho mọi việc khác. Cần nhớ FIT không phải chuẩn chung của ngành: nó là format mà code trong U-Boot (cả bản đầy đủ và SPL) hiểu được. Tầng nào không chạy code đó — Boot ROM, hay TF-A trên SoC ARMv8 — thì có cách đóng gói riêng, đưa FIT vào cũng vô nghĩa. Board khác, hệ khác: câu hỏi vẫn là “đơn vị được sign là gì, và tầng nào đọc hiểu được nó”.

2.2. Inside a FIT

mkimage đọc một file mô tả .its rồi đóng gói thành fitImage. Đây là file .its của dự án:

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
/dts-v1/;

/ {
    description = "FIT image for BeagleBone Black - kernel + dtb";

    images {
        kernel-1 {
            description = "Linux kernel";
            data = /incbin/("zImage");
            type = "kernel";
            arch = "arm";
            os = "linux";
            compression = "none";
            load = <0x80008000>;
            entry = <0x80008000>;
            hash-1 { algo = "sha256"; };
        };

        fdt-1 {
            description = "Device Tree Blob";
            data = /incbin/("am335x-boneblack.dtb");
            type = "flat_dt";
            arch = "arm";
            compression = "none";
            load = <0x88000000>;
            hash-1 { algo = "sha256"; };
        };
    };

    configurations {
        default = "conf-1";

        conf-1 {
            description = "BeagleBone Black boot config";
            kernel = "kernel-1";
            fdt = "fdt-1";

            signature-1 { ... };    /* explained in section 2.3 */
        };
    };
};

BBB: data của fdt-1 ghi am335x-boneblack.dtb — đây là tên thật trên đĩa, và là device tree của kernel, tức am335x-boneblack-kernel.dtb theo cách gọi ở đầu mục 2.

fitImage là một device tree, nhưng mô tả một gói hàng chứ không mô tả phần cứng. Property data không chứa đường dẫn tới file, mà chứa chính các byte của file (/incbin/ chép nguyên zImage vào lúc đóng gói), nên fitImage là một file tự đủ: chép đi đâu cũng mang theo cả kernel lẫn device tree. Vì vẫn là định dạng device tree, lúc boot U-Boot đọc nó bằng code phân tích device tree có sẵn bên trong. Hai node tầng trên cùng chia việc rõ ràng: images là nguyên liệu, configurations là công thức ghép.

images — nguyên liệu. Mỗi node là một mảnh (kernel-1, fdt-1) và tự đủ thông tin để U-Boot dùng nó. Cần vậy vì data chỉ là nội dung của file, không tự nói được mình là gì. Trước khi chép nó vào RAM và giao quyền, U-Boot phải trả lời được bốn câu hỏi, và mỗi trường sinh ra để trả lời một câu:

U-Boot cần biếtTrườngTrên board này
Đây là loại gì, chạy trên gì?type, arch, oskernel ARM chạy Linux; hoặc một device tree (flat_dt)
Có phải giải nén trước không?compressionnone — zImage tự giải nén khi chạy
Chép nó tới đâu trong RAM?loadkernel 0x80008000, device tree 0x88000000
Nhảy vào đâu để chạy?entrychỉ kernel có; device tree để đọc, không để chạy

Thêm vào đó là hash-1: trong .its nó chỉ khai báo algo = "sha256", tức là dùng thuật toán nào. Lúc đóng gói, mkimage hash data ra fingerprint SHA-256 rồi ghi thêm vào hash-1 (thành thuộc tính value). data vẫn nguyên là file gốc; fingerprint chỉ nằm cạnh nó để đối chiếu. Lúc này mới chỉ có fingerprint, chưa có signature. description chỉ là chú thích cho người đọc.

configurations — công thức ghép. images chỉ liệt kê, không node nào nói kernel-1 phải đi với fdt-1. Chuyện đó do configurations quyết định. Để thấy vì sao cần, hình dung một FIT phục vụ hai board:

1
2
3
4
5
6
7
8
9
10
images {
    kernel-1 { ... };    /* shared */
    fdt-1    { ... };    /* device tree for board A */
    fdt-2    { ... };    /* device tree for board B */
};

configurations {
    conf-1 { kernel = "kernel-1"; fdt = "fdt-1"; };   /* board A */
    conf-2 { kernel = "kernel-1"; fdt = "fdt-2"; };   /* board B */
};

Mỗi config chỉ chứa hai cái tên trỏ về images, không chứa dữ liệu, nên kernel-1 được dùng chung ở cả hai mà không phải copy. Lúc boot U-Boot được bảo boot config nào, rồi lần theo tên để lấy đúng cặp; nếu không chỉ định thì dùng config ghi ở default (dòng default = "conf-1" trong file).

FIT của dự án chỉ có một cặp nên configurations trông hơi thừa. Nó vẫn phải có, vì định dạng FIT vốn dựng cho nhiều image, và vì signature sẽ đặt vào đây ở mục 2.3: thứ cần bảo vệ là cách ghép, không chỉ từng mảnh.

Phần lõi của bài này. Với việc sign, chỉ ba thứ có ý nghĩa: data (nội dung thật), hash-1 (fingerprint của nó) và configurations (cách ghép cặp). Chúng quy về hai thứ đáng bảo vệ: dữ liệu (data, đã có fingerprint hash-1) và cách ghép cặp (conf-1). Nhóm trường nạp (type tới entry) không liên quan tới sign, nhưng không vô hại: khai sai thì kernel không chạy được.

2.3. Adding the Signature

FIT đến đây mới có fingerprint: hash-1 nằm cạnh dữ liệu và không có gì bảo vệ nó. Bước lock còn thiếu chính là signature, được thêm vào như sau.

Cặp key. Cặp key RSA-2048 được sinh bằng openssl, ra hai file, chỉ một trong hai là bí mật:

  • dev.key — private key. Không rời máy dev, và là file duy nhất không được commit.
  • dev.crt — public key (đóng trong một file chứng chỉ), dùng để chép vào U-Boot ở mục 3.

Khai báo signature. Thêm một node vào conf-1:

1
2
3
4
5
signature-1 {
    algo = "sha256,rsa2048";
    key-name-hint = "dev";
    sign-images = "kernel", "fdt";
};

algo là cặp thuật toán chạy nối tiếp: hash bằng SHA-256 trước, rồi RSA-2048 sign kết quả đó. key-name-hint là tên của key: mkimage dùng nó để tìm dev.key trong thư mục key, và chuỗi này cũng được ghi vào fitImage để lúc verify U-Boot biết tìm key nào (mục 3.3 quay lại điểm này). sign-images chọn những image nào nằm trong vùng sign.

Node này nằm trong conf-1 chứ không nằm trong images, vì thứ cần sign là cách ghép cặp, không phải từng mảnh riêng lẻ.

Bước sign. mkimage đọc .its, lấy dev.key, tính signature RSA, rồi ghi ngược vào một thuộc tính value mới trong signature-1. Cấu trúc cây không đổi. .its chỉ là khuôn; fitImage là bản đã đổ khuôn và có signature.

Quá trình tính ra signature như sau:

flowchart TB
    subgraph FIT["How the signature is computed"]
        subgraph IMAGES["images"]
            subgraph K["kernel-1"]
                direction LR
                KD["data<br/>copy of zImage"] -->|"SHA-256"| KH["hash-1 / value"]
                KI["type, load, entry..."]
            end

            subgraph F["fdt-1"]
                direction LR
                FD["data<br/>copy of .dtb"] -->|"SHA-256"| FH["hash-1 / value"]
                FI["type, load..."]
            end
        end

        subgraph CONFS["configurations"]
            subgraph C["conf-1"]
                CP["kernel = kernel-1<br/>fdt = fdt-1"]
                HASH_PROC["SHA-256"]
                RSA_PROC["RSA<br/>with private key dev.key"]
                SIG["signature-1 / value"]

                CP --> HASH_PROC
                HASH_PROC -->|"fingerprint"| RSA_PROC
                RSA_PROC --> SIG
            end
        end
    end

    %% Keep images above configurations
    IMAGES ~~~ CONFS

    %% Arrows from both blocks go straight down to the hash step
    K --> HASH_PROC
    F --> HASH_PROC

hash-1/value ai cũng tính lại được. signature-1/value thì chỉ người giữ private key tạo ra được. Lưu ý signature không sign trực tiếp data: nó sign vùng mô tả chứa các hash-1, và data được bảo vệ gián tiếp qua đó.

SHA-256 trong algo khác với SHA-256 của hash-1: hash-1 hash data của từng image, còn SHA-256 trong algo hash cả vùng mô tả, trong đó có sẵn các giá trị hash-1/value, cùng load, entry và cách ghép cặp ở conf-1. Vậy RSA không bao giờ thấy kernel, chỉ thấy fingerprint của vùng mô tả. Đó cũng là cách cặp ghép và địa chỉ nạp được bảo vệ, chứ không chỉ fingerprint của kernel.

Hai điều cần giữ trong đầu:

  • data vẫn là file gốc nguyên vẹn, và U-Boot load chính nó lên RAM; fingerprint và signature chỉ dùng để kiểm tra, không bao giờ được load.
  • data, fingerprint và signature đều là property nằm trong cùng một file fitImage.

Tới đây fitImage đã sign xong. Nhưng sign xong không có nghĩa là verify được: câu hỏi kế tiếp là thiết bị lấy key ở đâu để mở signature.


3. How Does the Verifier Get Its Key — and Become Mandatory?

Signature cần public key để mở, và bài học từ Part 1 là public key không được đi chung với gói. Vậy nó phải nằm ở đâu? Câu trả lời tổng quát: trong chính thứ mà tầng verify đang chạy, và tầng verify là tầng nạp payload. Với U-Boot, đó là device tree điều khiển (control DTB) của chính U-Boot, nằm trong u-boot.img, có sẵn trước khi board khởi động.

BBB: key nằm trong am335x-boneblack-uboot.dtb vì U-Boot là loader nạp fitImage. Nếu tầng nạp kernel là tầng khác (ví dụ ở Falcon mode, SPL nạp thẳng kernel và bỏ qua U-Boot proper), chỗ đặt key phải đổi theo, vì key đi theo tầng thực sự thực hiện verify.

Lưu ý: cần phân biệt key này với key trong eFuse, vì một board có root of trust thật (trên AM335x là biến thể HS) dùng hai lớp key khác nhau. Key của nhà sản xuất chip nằm trong eFuse, dùng để Boot ROM verify tầng đầu tiên (MLO). Key của người triển khai — thứ bài này đang dựng — nằm trong device tree của U-Boot, dùng để U-Boot verify kernel. Lớp thứ hai do chính người triển khai tự đặt vào, eFuse không làm thay bước đó.

BBB: trên bản GP chỉ có lớp của người triển khai, vì lớp eFuse không tồn tại.

3.1. The key-dev Node

Public key được nhúng vào am335x-boneblack-uboot.dtb bằng công cụ fdt_add_pubkey của U-Boot: nó đọc dev.crt rồi ghi thêm một node mới vào chính file dtb đó. Lệnh chạy:

1
2
./tools/fdt_add_pubkey -a sha256,rsa2048 -k keys -n dev -r conf \
    am335x-boneblack-uboot.dtb

Trước khi nhúng, dtb không có node signature. Sau khi nhúng, dtb có thêm node signature ở cấp trên cùng, bên trong là node key-dev chứa public key (đã rút gọn):

1
2
3
4
5
6
7
8
9
10
signature {
    key-dev {
        required = "conf";
        algo = "sha256,rsa2048";
        rsa,modulus = <0xd41d4307 0xdfa37a3f 0x59ea3bdf ...>;
        rsa,exponent = <0x00 0x10001>;
        key-name-hint = "dev";
        ...
    };
};

Đối chiếu với lệnh ở trên: -n dev đặt tên node là key-dev.

Public key nằm trong node này dưới dạng một nhóm số (các property rsa,...): fdt_add_pubkey đọc chúng từ file dev.crt rồi chép vào node, và lúc verify U-Boot đọc các số này ra để mở signature.

3.2. required = "conf"

Dòng required = "conf" trong node trên do tham số -r conf của lệnh nhúng key tạo ra. Nó bắt buộc mọi FIT muốn boot qua U-Boot đều phải được sign bằng đúng key này; FIT không có signature hay sign sai key đều bị từ chối. Thiếu dòng này, U-Boot vẫn mở được signature nếu có, nhưng gặp FIT không sign gì cả thì vẫn boot bình thường, và cổng verify ở mục 1 coi như không tồn tại.

3.3. How Does U-Boot Pick the Key?

Từ cái tên ghi trong fitImage, U-Boot tìm ra node chứa key theo quá trình sau:

flowchart LR
    subgraph SIGNING["Signing side"]
        K1["dev.key"] --> M["mkimage"] --> F["fitImage<br/>key-name-hint = dev"]
    end
    subgraph EMBEDDING["Embedding side"]
        C1["dev.crt"] --> A["fdt_add_pubkey<br/>-n dev"] --> D["U-Boot dtb<br/>signature / key-dev"]
    end
    F -.->|"at boot: add prefix key-,<br/>look up the node"| D

Lúc sign, key-name-hint = "dev" được mkimage ghi vào fitImage. Lúc verify, U-Boot đọc tên đó từ FIT, ghép tiền tố key- thành key-dev, rồi tìm node tên đúng vậy trong device tree của chính nó. Hai tên phải khớp: lệch thì U-Boot không tìm ra key và verify hỏng, dù signature và key đều đúng.


4. Putting the Check Together

Đến đây đã có đủ ba mảnh: signature nằm trong fitImage, public key nằm trong U-Boot, và cách U-Boot tìm key bằng tên. Ghép lại, cổng verify chạy theo thứ tự sau:

flowchart TB
    START["fitImage is already in RAM"]

    subgraph S1["Step 1 — check the signed region"]
        K["Find the key<br/>key-name-hint → node key-dev in U-Boot's dtb"]
        OPEN["Open the signature with the public key<br/>→ fingerprint A (from signing time)"]
        REHASH["Hash the signed region in the FIT, now<br/>→ fingerprint B"]
        CMP1{"A = B ?"}
        K --> OPEN --> CMP1
        REHASH --> CMP1
    end

    subgraph S2["Step 2 — check the data"]
        HASHD["Hash data of the kernel and the device tree"]
        CMP2{"Equal to the hash-1 values?"}
        HASHD --> CMP2
    end

    subgraph S3["Step 3 — run"]
        COPY["Copy each data field to the address in its load field<br/>kernel 0x80008000, device tree 0x88000000"]
        JUMP["Jump to the kernel"]
        COPY --> JUMP
    end

    HALT(["Boot halted"])

    START --> K
    START --> REHASH
    CMP1 -->|"Yes"| HASHD
    CMP1 -->|"No"| HALT
    CMP2 -->|"Yes"| COPY
    CMP2 -->|"No"| HALT

Hai lần so sánh trả lời hai câu hỏi khác nhau:

  • A so với B: cả vùng được sign — hai hash-1, cách ghép cặp ở conf-1, các thông tin nạp — có còn nguyên bản không. Đây là bước cần public key.
  • Hash của data so với hash-1: file thật có khớp với fingerprint vừa được xác nhận không. Bước này không cần key, nhưng chỉ đáng tin vì bước trước đã xác nhận các hash-1.

Hai điều nên để ý trong sơ đồ:

  • fitImage đã nằm trong RAM từ trước khi verify. Verify chạy trên bản đó, và chỉ khi cả hai lần so sánh đều khớp U-Boot mới chép data ra địa chỉ load để chạy. Lệch ở lần nào thì dừng ngay ở lần đó, chưa có gì được giao quyền.
  • Nếu FIT không có signature thì required = "conf" (mục 3.2) khiến U-Boot từ chối ngay từ đầu, không bước nào phía sau được chạy.

5. Questions to Ask on Another Board

Phạm vi bài dừng lại ở lúc kernel bắt đầu chạy: rootfs không được bảo vệ, không có dm-verity hay IMA.

Gom lại thành bộ câu hỏi để mang sang board khác:

Câu hỏiTrả lời trên BBB GPMục
Boot ROM verify được tầng đầu không, key nằm ở đâu?Không; eFuse trống1
Tầng nào làm điểm neo?Giả định U-Boot1
Đơn vị được sign là gì, tầng nào đọc hiểu?FIT, do code U-Boot đọc2
Key nằm ở đâu, cái gì khiến verify bắt buộc?key-dev trong control DTB, required = "conf"3
Cổng verify chạy theo thứ tự nào?Mở signature so vùng, hash data so hash-1, rồi mới chép ra load4
Còn tầng nào chưa được verify?MLO, u-boot.img, rootfs1, 5

Nguyên tắc chung: bảo vệ được cái gì phụ thuộc hoàn toàn vào việc bạn tin cái gì mà không cần hỏi tại sao. Trên HS, thứ đó là eFuse. Trên GP, thứ duy nhất được tin mà không hỏi là u-boot.img — và mọi câu trả lời ở các mục trên chỉ có giá trị chừng nào giả định đó còn đúng.

This post is licensed under CC BY 4.0 by the author.