1 điểm bởi GN⁺ 3 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • Tấn công chuỗi cung ứng đã trở thành vấn đề lớn hơn khi chi phí phân phối phần mềm giảm rất thấp và tự động hóa build·triển khai được dùng rộng rãi
  • Vào thập niên 1970 từng có khủng hoảng phần mềm vì rất khó tạo ra phần mềm có thể tái sử dụng, nhưng hiện nay kho gói và trình quản lý gói chỉ cần tên và phiên bản là có thể lấy mã và build
  • Việc cập nhật phụ thuộc tự động khiến thay đổi độc hại lan nhanh qua CI, và một cuộc tấn công chuỗi cung ứng tốt sẽ lan truyền nhanh đúng bằng tốc độ CI runner chạy
  • Vendoring — đưa toàn bộ phụ thuộc vào cùng kho lưu trữ của dự án — làm kho lớn hơn, nhưng ngăn thay đổi tự động và giúp thấy rõ hơn quy mô cũng như chi phí của phụ thuộc
  • Đây không phải lời giải cho mọi phần mềm, nhưng nhiều phần mềm nhỏ có thể hưởng lợi khi giảm các phụ thuộc có thể đột ngột thay đổi từ bên ngoài xuống còn mức 2~3 cái

Vấn đề

  • Tấn công chuỗi cung ứng ngày càng trở thành vấn đề lớn không chỉ vì bản chất của phần mềm hay bảo trì thay đổi, mà vì mô hình chi phí của việc chia sẻ và phân phối phần mềm đã trở nên cực thấp
  • Chi phí phân phối quá thấp đến mức người ta dùng rất nhiều tự động hóa dù có lãng phí, và bản thân tự động hóa thì hữu ích
  • Cứ vài tháng lại xuất hiện một cuộc tấn công chuỗi cung ứng mới làm hỏng một phần lớn mã nguồn của thế giới

Chúng ta đã đi đến đây như thế nào

  • Vào cuối thập niên 1960 và đầu thập niên 1970, người ta chưa thực sự biết cách tạo ra phần mềm có thể tái sử dụng, và điều này được gọi là khủng hoảng phần mềm
  • Nhu cầu phần mềm tăng theo cấp số nhân, nhưng năng lực tạo ra phần mềm mới đáp ứng độ phức tạp yêu cầu lại tăng chậm hơn
  • Giai đoạn này dẫn đến các nghiên cứu như tính mô-đun, lập trình có cấu trúc, và hầu như mọi hệ thống mô-đun của ngôn ngữ lập trình được tạo ra sau năm 1990 đều có thể lần ngược phả hệ về Modula-2
  • Sang thập niên 1990 và 2000, Internet tạo ra lời giải mạnh hơn, việc build và phân phối phần mềm trở nên rẻ hơn, và một phần lớn phần mềm người ta thực sự muốn dùng là mã nguồn mở
  • Dựa trên CPAN, CTAN và các bản phân phối Linux, rất nhiều kho góitrình quản lý gói đã xuất hiện; các công cụ này tìm, lấy và build phần mềm chỉ bằng file manifest, tên và thường là số phiên bản phần nhiều mang tính tùy ý
  • Từ tích hợp thủ công đến phụ thuộc tự động

    • Trước đây, cách tốt để xây dựng một hệ thống phần mềm phức tạp là cẩn thận ghép các mảnh đang hoạt động bằng tay, và về cơ bản các bản phân phối Linux làm việc này
    • Năm 2003, việc build SDL với đầy đủ tính năng đau đớn đến mức có thể mất vài ngày, và không cần phải hoài niệm thời đó
    • Khi đã có bản phân phối Linux như một môi trường nền đã biết, nhiều phần mềm tùy biến có thể vận hành trong thế giới riêng của nó mà không cần quá bận tâm đến các phần khác của hệ thống
    • Khi giao tiếp với phần mềm khác, chúng thường đi qua file hoặc network socket dùng các giao thức quen thuộc
    • Ngày càng có nhiều phần mềm tốt được build từ đầu bằng Rust hoặc Go, hoặc được triển khai dưới dạng Docker container, và các phần mềm như vậy hầu như không tương tác với thư viện hệ thống
    • Thay vì khớp với tập phần mềm do bản phân phối OS cung cấp, cách làm phổ biến là để hệ thống build tự lấy các thư viện cần thiết
  • Cuộc khủng hoảng theo hướng ngược lại

    • Hiện nay, ngược với thập niên 1970, chúng ta có một cuộc khủng hoảng nơi con người tái sử dụng phần mềm quá nhiều đến mức chương trình trở nên tệ hơn
    • Việc phân phối phần mềm vẫn rất rẻ, nhưng sử dụng phần mềm thì vẫn có chi phí
    • Trong thời gian dài, chi phí lớn nhất là độ phức tạp của việc build phần mềm và làm cho nó chạy được trên máy tính, nhưng vấn đề đó phần lớn đã biến mất nhờ tự động hóa
    • Giờ đây chúng ta build, triển khai và dùng nhiều phần mềm hơn rất nhiều, và cái giá xuất hiện dưới dạng dependency hell, phình to, thời gian build dài, hay việc gói hoặc trình quản lý gói biến mất
    • Vấn đề lớn nhất là tấn công chuỗi cung ứng
  • Cấu trúc lan rộng của tấn công chuỗi cung ứng

    • Tấn công chuỗi cung ứng là vấn đề lâu đời ngang với phần mềm mã nguồn mở
    • Trước đây, nỗ lực vá độc hại nhằm đưa uid = 0 thay cho uid == 0 vào Linux kernel là bản vá kernel độc hại đầu tiên được ghi nhận ngoài thực tế, và đó thuộc loại nỗ lực tấn công chuỗi cung ứng
    • Lý do các cuộc tấn công chuỗi cung ứng trở nên lớn hơn và nghiêm trọng hơn trong 10 năm gần đây là vì hệ thống build đã được tự động hóa để lấy mã nguồn và phân phối nó
    • Hệ thống CI thường chạy với mọi thay đổi mã hoặc thay đổi lớn, và những thay đổi này sẽ tự động sẵn sàng cho mọi người phụ thuộc vào đoạn mã đó
    • Hệ thống CI của phía phụ thuộc cũng sẽ lấy thay đổi đó và bao gồm cả mã độc mới chèn vào, và một cuộc tấn công chuỗi cung ứng tốt sẽ lan như cháy rừng đúng bằng tốc độ các CI runner chạy
    • Có những cách làm chậm tấn công chuỗi cung ứng như dependency cooldown, nhưng sẽ nảy sinh tranh cãi về chính sách và trách nhiệm

Giải pháp

  • Cốt lõi là không để các hệ thống build như npm, cargo tự động lấy phụ thuộc từ vị trí mạng ở mọi lần build, mà đưa toàn bộ phụ thuộc đi kèm với phần mềm
  • Vendor mọi phụ thuộc vào dự án, sao chép nội dung source control ở upstream vào kho git rồi commit
  • Khi upstream có cập nhật thì tải về và sao chép lại; nếu chán làm tay thì có thể để công cụ build tự động hóa việc này
  • Nếu đã có lockfile, chỉ cần làm cho nó gắn với toàn bộ cây mã nguồn nằm trong source control
  • Sở hữu mọi dòng mã nguồn theo cách kiểm soát chặt chẽ
  • Chi phí và đánh đổi

    • Kho lưu trữ sẽ lớn hơn, nhưng dung lượng đĩa thì rẻ
    • Chi phí truyền tải kém rẻ hơn đĩa, nhưng trong tranh luận này vẫn phải chấp nhận
    • Thời gian build có vẻ sẽ tăng, nhưng vì đằng nào bạn cũng đang build lại các phụ thuộc đó nên không nhất thiết sẽ tăng
    • Việc tái sử dụng mã có thể khó hơn, và với các chương trình như client và server cùng dùng thư viện giao thức chia sẻ, đây có thể là vấn đề thật sự
    • Nhưng những chương trình như vậy vốn đã có vấn đề lệch phiên bản và phải xử lý nó, nên về lâu dài việc buộc phải chú ý hơn cũng không hẳn tệ hơn
  • Tường lửa chặn tấn công chuỗi cung ứng

    • Nếu không tự động cập nhật phụ thuộc, mọi gói trong hệ sinh thái sẽ trở thành một tường lửa chống tấn công chuỗi cung ứng
    • Cách này cũng chặn cả việc lan truyền bug fix và bản vá, nhưng nếu là bản sửa quan trọng thì đằng nào con người cũng sẽ tự đi tìm
    • Những bản sửa mà con người không đi tìm thường là không quá quan trọng
    • Có thể đạt hiệu quả tương tự bằng cách loại bỏ semver hay ý niệm “hai đoạn mã khác nhau phải hoạt động giống nhau” khỏi hệ thống build, và coi mọi số phiên bản là những thực thể duy nhất, không liên quan nhau
    • Vấn đề của semver là nó biểu đạt ý định của con người chứ không phải thực tế, mà ngay cả vậy cũng chỉ hoạt động khi nó được dùng tương đối đúng
    • Cách coi số phiên bản là duy nhất không giải quyết được các vấn đề như phụ thuộc biến mất, bị sửa đổi ác ý, hoặc nội dung gói bị hỏng theo cách khác
  • Tính hiển thị của phụ thuộc

    • Vendor toàn bộ phụ thuộc không chỉ làm chậm thay đổi tự động mà còn tăng nhẹ chi phí sử dụng phụ thuộc
    • Mức tăng chi phí này không đến mức không thể phục hồi, mà chỉ đủ để khiến người ta suy nghĩ thêm một chút trước khi dùng mã upstream
    • Nó trở thành một cơ chế mềm buộc phải hỏi lại “có thực sự cần không” khi thêm phụ thuộc mới
    • Tính hiển thị của phụ thuộc tăng lên, và sự phình to vốn ẩn sau phụ thuộc sẽ bớt bị che giấu
    • Nếu thêm một thư viện đơn giản tưởng chỉ khoảng 200 dòng mà hóa ra là 50.000 dòng, sẽ rõ ràng hơn rằng phải dừng lại và hỏi vì sao
    • Tính chất gần như ma thuật của phụ thuộc giảm đi, và bạn có thể lần theo đường bug trong codebase dẫn sang mã của người khác dễ hơn
  • Cây phụ thuộc và vấn đề chia sẻ

    • Nếu mặc định vendor mọi thứ, điều đó có thể dẫn tới cây phụ thuộc phẳng hơn và rộng hơn
    • Việc đi tới mức thư viện khổng lồ như Boost hay Qt của C++ là không mong muốn
    • Những thư viện khổng lồ đó tồn tại vì việc tạo và dùng các thư viện C/C++ nhỏ là quá khó
    • Có một giả định rằng thay vì tự tìm cả cách build những thứ như Boost hay Qt, tốt hơn là để một nhà tích hợp hệ thống như bản phân phối Linux làm điều đó một lần
    • Nhược điểm thực sự là các phụ thuộc bắc cầu sẽ không được chia sẻ
    • Nếu lib A và lib B đều phụ thuộc vào Z, việc khử trùng lặp không phải là bất khả thi nhưng sẽ khó hơn, và cần con người tự làm hoặc công cụ tinh vi hơn
    • Ngay cả khi phụ thuộc bắc cầu được chia sẻ thì vấn đề vẫn phát sinh, và bản thân việc có phụ thuộc bắc cầu đã là một phần của vấn đề
    • Cho phép thư viện chỉ định phụ thuộc bắc cầu là hành vi trao quyền kiểm soát chương trình cho người khác

Phân tích

  • Không phải mọi phần mềm đều có thể dùng cách này
  • Việc vendor và build toàn bộ Redis như một phần của triển khai backend web app không hẳn đặc biệt hợp lý
  • Tuy vậy, nếu việc triển khai đã được tự động hóa bằng Ansible hay Docker image, thì có thể trên thực tế bạn đã làm gần giống vậy rồi
  • Cách này có một giới hạn trên về độ phức tạp mà nó chịu được, nhưng các công ty monorepo khổng lồ như Google và Facebook cho thấy giới hạn đó có thể cao hơn tưởng tượng
  • Đến một lúc nào đó, phụ thuộc sẽ chạm vào hệ điều hành, mà bản thân hệ điều hành là một phụ thuộc lớn với rất nhiều vấn đề riêng
  • Ý tưởng unikernel cho web backend khá hấp dẫn, nhưng công cụ thực tế còn có vấn đề và chúng ta chưa đến được giai đoạn đó
  • Bản phân phối Linux và môi trường build

    • Cách này không phải phương pháp để tạo ra các hệ thống tương tác hoàn chỉnh như bản phân phối Linux hay BSD
    • Các hệ thống như vậy có rất nhiều chương trình và thư viện phải cùng hoạt động, nên đó là một bài toán khác
    • Nếu đẩy nguyên tắc này đến cùng, nó sẽ gần với cách làm của Nix hay Guix
    • Ý niệm phải lắp ráp đúng “môi trường build” gần giống một cách giải quyết lười biếng và chưa đầy đủ cho câu hỏi “build phần mềm như thế nào”
    • Ý niệm đó là tàn dư của thời người ta build phần mềm một lần trên một máy mini nào đó rồi chia sẻ rộng rãi dưới dạng binary
    • Ngày nay chúng ta build phần mềm tại chỗ nhiều hơn rất nhiều so với thập niên 1970
  • Phạm vi áp dụng

    • Đây không phải lời giải vạn năng, nhưng rất nhiều phần mềm có thể áp dụng và hưởng lợi
    • Phần lớn phần mềm là nhỏ, còn các dự án lớn thì vốn đã phải giải quyết nhiều vấn đề kiểu này
    • Có rất nhiều thư viện chỉ thuần tính toán hoặc chỉ tiếp xúc với bên ngoài qua I/O cơ bản, dễ di chuyển như file và network socket
    • Các ví dụ như thư viện nén, libcurl, thư viện TUI, hay Django đều có thể được xem là đối tượng để vendor
    • Việc vendor giúp gần như tránh được tình trạng triển khai hoặc build trên hệ thống mới bị hỏng một cách khó hiểu vì xung đột phiên bản hoặc bug đi vào từ một bản vá đột ngột
    • Mục tiêu là giảm các phụ thuộc có thể thay đổi từ bên ngoài mà không báo trước xuống không phải 200~300 cái, mà nhiều nhất chỉ còn mức 2~3 cái

Kết luận

  • Giảm cập nhật phụ thuộc tự động và để dự án trực tiếp sở hữu cả mã nguồn phụ thuộc có thể làm chậm sự lan truyền tự động của tấn công chuỗi cung ứng
  • Tăng nhẹ chi phí sử dụng phụ thuộc và tăng tính hiển thị sẽ giúp phát hiện dễ hơn việc tái sử dụng không cần thiết và sự phình to bị che giấu
  • Cách này không phù hợp với mọi hệ thống, nhưng có lợi ích thực dụng cho phần mềm nhỏ và nhiều thư viện

1 bình luận

 
Ý kiến trên Lobste.rs
  • Theo tôi thì trình quản lý gói của Zig là một phương án dung hòa khá ổn
    Mọi gói đều được cố định bằng hàm băm nội dung nên mặc định coi như đã có lockfile, tránh được vấn đề “kho lưu trữ upstream đột nhiên biến thành độc hại”, nhưng vẫn còn vấn đề “kho lưu trữ upstream biến mất”
    Tuy vậy, vì vừa có cache toàn cục/cục bộ vừa dựa trên hàm băm nội dung, nếu kho upstream biến mất thì chỉ cần ném tarball của bản sao cục bộ vào nơi cần thiết là được
    Có vẻ đây là sự thỏa hiệp tốt giữa “vendor mã nguồn” và “phần mềm đơn giản, có thể tái sử dụng”

    • Có lẽ cũng có thể mở rộng cách đó cho toàn bộ phần mềm, và nghe khá hay
      Đặt toàn bộ mã nguồn vào kho lưu trữ định địa chỉ theo nội dung, rồi mỗi chương trình chỉ cần được băm dựa trên hàm băm của các đầu vào
    • Tôi nhìn chung đồng ý, nhưng cũng hơi tò mò không biết có thể tấn công mô hình đó như thế nào
      Chắc phải sửa lockfile hoặc tìm va chạm hàm băm, mà cả hai đều có vẻ không dễ
      Tuy nhiên, vì đã quen với hệ sinh thái cargo nên tôi vẫn chưa hoàn toàn thích nó. Khi nâng một dependency thì các dependency bắc cầu của nó cũng thường bị nâng theo mà không nói rõ, và những thứ khác khớp với phạm vi semantic version cũng bị thay đổi cùng
  • Nếu gọi là “tấn công chuỗi cung ứng” thì tôi thấy không đúng lắm, vì ở đây đâu có hợp đồng ký kết với đề nghị và đối giá nên không hẳn là chuỗi cung ứng
    Mặt khác, xét theo góc độ bảo đảm dependency không bị thay đổi từ bên dưới, thì lockfile có chứa hàm băm hoặc cách chọn phiên bản tối thiểu của Go cũng tương đương với việc vendor dependency
    Tôi hiểu vendor tạo ra thêm ma sát, nhưng nếu đi đến cực đoan thì cuối cùng sẽ thành tự tự viết hoặc tệ hơn là biến dependency thành mã sinh ad-hoc, nên tôi vẫn nghĩ dùng phần mềm do chuyên gia miền viết và đã được kiểm chứng kỹ thì tốt hơn
    Tôi từng làm việc này ở Facebook, và tôi sẽ không khuyên ai làm theo cách quản lý dependency bên thứ ba ở đó. Với dependency trực tiếp của một Rust crate cụ thể, trên toàn bộ fbsource chỉ được phép đồng thời tối đa hai phiên bản không tương thích semantic version. Muốn cập nhật dependency thì phải gánh luôn chi phí cập nhật toàn bộ fbsource
    Có thể cách đó hợp với Facebook, nhưng tôi không thấy nó đặc biệt xuất sắc hay bền vững

    • Tôi thắc mắc vì sao lại là “tối đa hai”. Là để di trú dần dần từ phiên bản cũ sang phiên bản mới sao?
      Tôi nghi rằng chuyện “không đặc biệt xuất sắc hay bền vững” có lẽ là hàm của quy mô hơn là bản thân chính sách. Nếu cho phép nhiều phiên bản thì lại nảy sinh vấn đề khác: đa số ngôn ngữ hiện đại, trừ TypeScript, chủ yếu hoặc hoàn toàn dùng kiểu danh nghĩa, nên mỗi thay đổi gây vỡ sẽ chặn tái sử dụng kiểu giữa các phiên bản trừ khi dùng “semver trick”
      Thời Log4Shell, tôi nhớ rất rõ là các công ty có nhiều phiên bản nằm rải rác khắp nơi đã khổ sở hơn nhiều khi nâng cấp so với các công ty có ít phiên bản hoặc đã cố định phiên bản
    • Đúng vậy, thế thì cứ gọi là tấn công dependency vậy <3
  • Theo The Third Networking Truth, “với đủ lực đẩy thì lợn cũng bay được. Nhưng điều đó không có nghĩa đây là ý hay”
    Nhiều thực hành được trích dẫn từ những nơi như Google/Facebook chỉ hoạt động được vì các công ty đó có thể dồn vào đủ lực đẩy
    Ví dụ, tôi biết có nơi duy trì cả một đội ngũ còn lớn hơn toàn bộ công ty tôi chỉ để hỗ trợ monorepo và các lựa chọn liên quan đến dependency. Họ chịu nổi, còn phần lớn chúng ta thì khó

  • Quan điểm hay. Tôi rất đồng ý với ý “nếu vendor mọi dependency thì chi phí dùng dependency sẽ tăng lên”
    Tuy nhiên, đừng sao chép rồi dán libcurl vào. Đó là chiến lược ổn với hầu hết thư viện, nhưng không phải lời khuyên tốt cho chương trình C xử lý đầu vào mang tính đối kháng. Bạn không thể làm tốt hơn hệ điều hành trong việc giữ libcurl an toàn
    Một điểm tôi chưa từng nghĩ tới là việc các trình quản lý gói cho người dùng cuối như apt xuất hiện trước, còn trình quản lý gói ở cấp ngôn ngữ lại xuất hiện sau, ít nhất cũng hơi kỳ lạ
    Theo tôi, điều này thực sự đã gây ra nhiều vấn đề. Nhìn vào rubygems đầu thập niên 2000 thì khá rõ là họ đang cố làm “apt cho Ruby”, với mặc định là cài đặt toàn hệ thống chứ không phải quản lý theo từng dự án. Phải mất hàng chục năm và thêm bundler mới đảo ngược được thiệt hại từ sai lầm đó, trong khi nếu ngay từ đầu đã thừa nhận nhu cầu cô lập theo dự án thì có lẽ đã không cần bundler
    Python vẫn còn đang dọn dẹp mớ hỗn độn này, và Perl có lẽ cũng thế dù tôi không rõ lắm

    • Tức là vẫn có giới hạn nhỉ :-) Chỉ là khó xác định chính xác nên vạch ranh ở đâu
      Về mặt lịch sử, trình quản lý gói ban đầu là cách để xây dựng hệ thống, và những hệ thống đó có nhiều người dùng, môi trường desktop, cùng rất nhiều phần mềm phải phối hợp với nhau
      Việc build phần mềm tốn nhiều thời gian và bộ nhớ, còn phần mềm thì quá nhiều so với dung lượng đĩa và RAM, nên tái sử dụng thư viện là điều quan trọng
      Khi web app trỗi dậy, phần lớn máy tính quan trọng trở thành các máy chủ chỉ chạy vài chương trình trong suốt vòng đời của chúng, còn đĩa và RAM thì rẻ đủ để kích thước mã nhị phân bớt quan trọng hơn
      Các công cụ xây dựng hệ thống không theo kịp thay đổi của thời đại, nên đa số người làm phần mềm chỉ cần công cụ giúp tạo ra tốt một chương trình đơn lẻ, chứ không phải một hệ thống khổng lồ liên kết chặt chẽ với vô số thư viện dùng chung
      Song song với lịch sử đó còn có mạch chuyện “C không có hệ thống module tử tế”, nhưng ở đây thì ít quan trọng hơn
  • Có thể tôi sai, nhưng có vẻ một nhược điểm là scanner sẽ không phát hiện được lỗi trong dependency đã sao chép về
    Nếu vậy, những vấn đề tiềm ẩn mà đáng ra bạn đã được cảnh báo có thể âm thầm tồn tại

    • Xét đến số lượng dương tính giả mà các scanner này tạo ra thì biết đâu đó lại là ưu điểm
      Scanner rất hữu ích trong việc chỉ ra những thứ có thể là vấn đề, nhưng chúng cực kỳ phiền khi đột ngột khiến bạn phải hoãn việc đã lên kế hoạch chỉ để sửa thứ mà scanner tưởng là vấn đề nhưng thực ra không phải
  • Nếu làm như đề xuất: đưa mọi dependency vào phần mềm, sao chép phần quản lý mã nguồn upstream vào kho git rồi commit lại, và nếu chán làm tay thì để công cụ build tự động hóa, chẳng phải cuối cùng là ta đi hết một vòng rồi lại đang đưa phần mềm bên thứ ba vào mà không thực sự xem xét nó sao?

    • Nếu đọc tiếp thì họ nói có thể đạt hiệu quả tương tự bằng cách bỏ semantic version hay ý niệm “hai đoạn mã khác nhau phải hoạt động như nhau” trong hệ thống build, và coi mọi số phiên bản là hoàn toàn riêng biệt, không liên quan đến nhau
      Nhưng cách đó không giải quyết được vấn đề dependency biến mất hoặc bị can thiệp, hay việc ai đó chỉnh sửa nội dung gói theo cách khác. Nó giống một kiểu tối ưu hóa hơn, và theo tôi là tối ưu hóa quá sớm. Có thể một ngày nào đó sẽ đi theo hướng đó, nhưng không nên lấy nó làm điểm khởi đầu