GCC chia nhỏ chuỗi khiến lệnh grep đường dẫn trong binary thất bại
Bài viết của một kỹ sư giải thích rằng từ GCC 12, trình biên dịch có thể chia nhỏ các hằng chuỗi để tối ưu hóa, khiến đường dẫn tuyệt đối trong binary không còn xuất hiện nguyên vẹn. Điều này làm phương pháp kiểm tra tự động bằng lệnh grep hoặc strings mà nhiều bản phân phối Linux dùng để phát hiện đường dẫn không thể di chuyển trở nên không đáng tin cậy.

Nhiều bản phân phối Linux và các trình quản lý gói thường dùng lệnh grep để rà soát các tệp nhị phân vừa biên dịch nhằm phát hiện những đường dẫn tuyệt đối không thể di chuyển (non-relocatable absolute path) — nếu binary chứa đường dẫn kiểu /path/from/build/machine, chương trình có thể không chạy được trên máy của người dùng cuối. Nhưng theo một bài viết kỹ thuật mới đây, cách kiểm tra này không còn đáng tin cậy khi biên dịch bằng GCC nhắm tới kiến trúc x86-64.
Chuỗi hằng không còn nằm nguyên vẹn trong binary
Tác giả đưa ra một ví dụ C++ đơn giản: tạo một std::string từ hằng chuỗi /home/linuxbrew/.linuxbrew rồi in ra màn hình. Chương trình biên dịch bằng g++ -O2 vẫn chạy đúng và in đúng đường dẫn, nhưng khi tìm chuỗi này trong tệp thực thi thì kết quả lại bằng 0.
$ grep -acF /home/linuxbrew/.linuxbrew main
0
$ strings ./main | grep -cF /home/linuxbrew/.linuxbrew
0
Đáng chú ý hơn, khi dùng strings rồi tìm theo tiền tố /home, ta lại thấy một đường dẫn trông lạ hoắc: /home/linuxbrew/xbrew/.linuxbrew. Thư mục con xbrew hoàn toàn không tồn tại — đây chỉ là "ảo giác" do cách GCC lưu chuỗi.
Cơ chế "chia mảnh" của GCC
Lý do là GCC tối ưu hóa việc gán std::string{PREFIX}. Trình biên dịch chia chuỗi thành hai mảnh chồng lấn, mỗi mảnh dài 16 byte, vừa khít thanh ghi vector xmm:
/home/linuxbrew/.linuxbrew
|--------------|.......... - hằng số 1
..........|--------------| - hằng số 2
Nhờ vậy chuỗi được sao chép vào bộ đệm của std::string bằng các lệnh movdqa/movups thay vì từng byte. Hai mảnh được lưu ở .LC0 và .LC1, tình cờ nằm cạnh nhau trong binary, và khi nối lại thành một chuỗi byte liên tục thì ta "nhìn thấy" một đường dẫn méo mó.
Hiện tượng này xảy ra khi hằng chuỗi được sao chép vào một bộ đệm, như với std::string, strcpy hay memcpy. Ngược lại, chuỗi chỉ được truyền dưới dạng con trỏ, ví dụ fopen(PREFIX "/x", "r"), vẫn giữ nguyên trong binary.
Trùng lặp các mảnh
Một số trình quản lý gói như Conda và Spack cài đặt package vào một tiền tố (prefix) được đệm dài trong lúc build, với ý tưởng có thể "di chuyển" (relocate) sang tiền tố ngắn hơn do người dùng chọn bằng cách vá trực tiếp binary. Conda dùng chuỗi lặp _placehold trong tên thư mục để kéo dài tiền tố.
Nhưng ngay cả khi chọn một đường dẫn dài 256 byte bằng cách đệm theo kiểu Conda, ta cũng không thấy lại tiền tố gốc. Thay vào đó chỉ thu được một chuỗi ngắn hơn nhiều, 176 byte (tức 11 lần 16 byte). Nguyên nhân là vì _placehold lặp lại mỗi 10 ký tự, nên các mảnh 16 byte lặp lại mỗi bội số chung nhỏ nhất của 10 và 16, tức 80 byte. GCC chỉ cần lưu 5 hằng số 16 byte khác nhau cho phần đệm.
Tối ưu này có ích không?
Tác giả cho rằng với hằng chuỗi thì lợi ích gần như không có. Clang không chia chuỗi thành các mảnh nhưng vẫn sinh ra các lệnh gần như giống hệt. Ví dụ, với một đường dẫn dài và nhắm tới AVX2 (-march=x86-64-v3), Clang đặt chuỗi trong vùng .rodata.str1.1 và sao chép 53 byte bằng hai lệnh nạp/lưu 32 byte chồng lấn ở độ lệch 21 và 0. GCC thì nạp hai mảnh từ .rodata.cst32 — không khác biệt đáng kể về mặt thực thi.
Về hiệu năng, gần như không đo được sự khác biệt. Với các mảnh lặp lại, GCC có thể nhỉnh hơn chút vì mảnh lặp được nạp một lần rồi ghi nhiều lần. Về kích thước, các mảnh của GCC có thể chiếm nhiều chỗ hơn cả chuỗi gốc: byte chồng lấn bị lưu hai lần và các mảnh được căn chỉnh theo 16, 32 hoặc 64 byte. Đường dẫn 53 byte ở trên tốn 64 byte khi chia thành hai mảnh, so với 54 byte khi giữ nguyên chuỗi. Tối ưu chỉ có lợi nếu giúp loại bỏ trùng lặp (deduplication), chẳng hạn khi nhiều chuỗi chia sẻ một tiền tố dài ít nhất 16 byte với SSE2, 32 byte với AVX2 và 64 byte với AVX-512.
Có cách tắt được không?
Theo bài viết, ngoài việc biên dịch với -Os thì gần như không thể tắt. Từ GCC 10, cơ chế chia mảnh được kích hoạt với chuỗi dài tới 256 byte ngay cả khi không chỉ định -march. Từ GCC 12, giới hạn này phụ thuộc độ rộng vector của mục tiêu: lên tới 512 byte với AVX2 và 1024 byte với AVX-512. Cách duy nhất để kiểm soát là đặt độ rộng vector tối đa 128 bit bằng -mstore-max=128, nhưng cách này chỉ khôi phục mặc định cũ và chuỗi tới 256 byte vẫn bị chia mảnh.
Quét hậu kiểm có phải là cách làm sai?
Tác giả thừa nhận có ý kiến cho rằng các đợt kiểm tra đường dẫn tuyệt đối sau build vốn đã mong manh. Debian đi xa hơn: họ kiểm tra rằng một gói có thể được build lại từng bit giống hệt trong các điều kiện khác nhau — máy khác, người dùng khác, thời gian, hostname, locale khác — một cách đáng tin cậy hơn nhiều.
Tuy vậy, tác giả vẫn cho rằng giữ hằng chuỗi nguyên vẹn trong binary là hữu ích, kể cả trên Debian, vì nó giúp binary dễ đọc hiểu hơn. Chạy strings ./binary là cách đơn giản và tiện lợi để nhìn vào một khối dữ liệu không rõ nguồn gốc; lợi ích này vượt qua khoản tiết kiệm nhỏ từ việc chia mảnh và loại trùng lặp. Tác giả so sánh với việc Fedora và Ubuntu bật lại frame pointer mặc định dù tốn thêm chút chi phí, chỉ để các công cụ profiling và tracing hoạt động ổn định hơn.
Ngoài ra còn vấn đề vá binary — dù bị coi là mong manh nhưng vẫn phổ biến trong Conda và Spack. Khi cài vào vị trí do người dùng chọn, các đường dẫn tuyệt đối được vá tại chỗ, có thể hình dung như một lệnh sed phức tạp. Cách này chỉ hoạt động nếu đường dẫn được lưu nguyên một khối.
Tác giả đã mở một luồng thảo luận trên danh sách gửi thư gcc-patches, kèm bản vá thử nghiệm bổ sung tùy chọn để từ chối tối ưu hóa này.
Góc nhìn
Với các lập trình viên và kỹ sư DevOps Việt Nam tham gia đóng gói phần mềm, đóng gói container hoặc xây dựng quy trình CI/CD, bài viết là lời nhắc rằng các bước kiểm tra tự động đơn giản có thể cho kết quả "âm tính giả". Nếu quy trình của bạn dựa vào lệnh grep hay strings để đảm bảo binary không chứa đường dẫn build, hãy coi đó là lớp kiểm tra bổ sung chứ không phải bảo đảm. Việc kiểm tra khả năng tái tạo bản dựng (reproducible build), hoặc thử cài đặt gói ở một tiền tố khác rồi chạy thử, đáng tin cậy hơn.
Bên cạnh đó, cần lưu ý rằng những công cụ như Conda hay Spack dựa vào việc vá đường dẫn tại chỗ sẽ chịu ảnh hưởng trực tiếp nếu chuỗi bị chia mảnh. Khi chọn trình quản lý gói hoặc thiết kế cách đóng gói nội bộ, đây là một chi tiết về trình biên dịch đáng để kiểm tra thay vì mặc định tin tưởng vào cơ chế tương thích.
Nguồn: stoppels.ch · Quy trình biên tập


