1 điểm bởi GN⁺ 2024-04-28 | 1 bình luận | Chia sẻ qua WhatsApp
  • Hubris là một hệ điều hành nơi các tác vụ cô lập giao tiếp qua IPC, và với lời gọi hệ thống thứ 13 là REPLY_FAULT, máy chủ có thể kết thúc các yêu cầu sai từ client bằng fault thay vì trả về giá trị lỗi
  • Từ phía client, IPC trông giống như một lời gọi hàm, nhưng vì các tác vụ được biên dịch riêng biệt, trình biên dịch không thể chặn hoàn toàn mã thao tác sai, các byte không thể diễn giải, hoặc loaned memory không phù hợp
  • Trong các chương trình Hubris bình thường, những lỗi như vậy hầu như không xuất hiện nhờ cấu hình build và mã Rust được sinh ra, nên nếu ép mọi lời gọi phải dùng Result<T, IpcError>unwrap() thì sẽ làm tăng kích thước mã và chi phí thời gian chạy
  • Kernel sẽ giết ngay tác vụ vi phạm điều kiện tiên quyết của lời gọi hệ thống mà không trả mã lỗi, và REPLY_FAULT mở rộng cùng chính sách fail-fast đó sang cả phản hồi của máy chủ
  • Thiết kế này giúp bộc lộ nhanh việc dùng API sai, nhưng khiến fuzz test hoặc các tác vụ chaos gửi IPC và lời gọi hệ thống ngẫu nhiên gần như bị khởi động lại ngay lập tức, nên khó kiểm thử

Vị trí của Hubris IPC và REPLY_FAULT

  • Hubris đặt một kernel nhỏ độc lập với ứng dụng ở trung tâm, còn phần lớn mã như driver, logic ứng dụng và network stack nằm trong các tác vụ cô lập được biên dịch tách biệt
  • Giao tiếp giữa các tác vụ được thực hiện bằng các lời gọi hệ thống IPC do kernel triển khai
    • RECV: lấy thông điệp nhận có ưu tiên cao nhất, hoặc block cho đến khi có thông điệp đến
    • SEND: dừng tác vụ gọi, chuyển thông điệp và quyền điều khiển cho tác vụ nhận, rồi chờ đến khi nhận được phản hồi
    • REPLY: gửi phản hồi cho tác vụ đã SEND trước đó để nó có thể chạy lại
  • Trong Hubris, client và server không phải danh tính cố định mà là vai trò do tác vụ đảm nhận
    • Tác vụ dùng SEND đóng vai trò client
    • Tác vụ dùng RECVREPLY đóng vai trò server
    • Một tác vụ có thể là server với tác vụ này nhưng lại là client với tác vụ khác

Những lỗi mà trình biên dịch bỏ sót ở ranh giới tác vụ

  • Trong lời gọi hàm thông thường, trình biên dịch và linker đảm bảo khá nhiều về kiểu dữ liệu và đích gọi
    • Nếu một hàm Rust nhận đối số String, trình biên dịch sẽ chặn việc caller truyền bool
    • Việc nhầm đích gọi như định gọi pet_cat nhưng lại gọi fire_missiles cũng thường không xảy ra
  • IPC của Hubris vượt qua ranh giới tác vụ và mỗi tác vụ được biên dịch thành một chương trình riêng, nên trình biên dịch không thể trực tiếp xác minh toàn bộ quan hệ IPC
  • Các lỗi mà máy chủ IPC có thể gặp nhìn chung có ba loại
    • Mã thao tác không khớp với giao diện, ví dụ giao diện chỉ có hai thao tác nhưng lại nhận “operation number 48”
    • Một nhóm byte không thể diễn giải thay vì kiểu thông điệp mong đợi, hoặc thông điệp quá ngắn hay quá dài
    • Thiếu loaned memory cần thiết, hoặc đáng ra cần bộ nhớ ghi được nhưng lại nhận bộ nhớ chỉ đọc

Vì sao không ép chương trình bình thường phải xử lý lỗi

  • Trong các chương trình Hubris bình thường, những lỗi IPC như vậy được cấu hình để không xảy ra
    • Liên kết giữa các tác vụ được cấu hình bằng thiết lập của hệ thống build nên khó nhầm lẫn lẫn nhau
    • Client tạo và gửi IPC bằng mã Rust được sinh ra
    • Server cũng xử lý kết quả bằng một bộ mã Rust sinh riêng
  • Nếu mọi thao tác IPC đều phải trả về Result<T, IpcError>, thì chương trình bình thường sẽ phải thêm unwrap() cho những lỗi trên thực tế không thể gặp
    • unwrap() là gánh nặng về kích thước mã
    • Đồng thời còn phát sinh chi phí kiểm tra những lỗi sẽ không xảy ra trong thời gian chạy
  • Nếu đặt unwrap() hoặc panic! bên trong mã sinh ra, có thể tập trung vị trí panic để giảm ảnh hưởng tới kích thước mã, nhưng chi phí runtime vẫn giữ nguyên
  • Để hỗ trợ mã lỗi phổ quát, mọi thao tác đều phải tuân theo cùng một quy ước mã hóa lỗi
    • Mọi thao tác đều phải có khả năng trả lỗi
    • Mọi thao tác đều phải mã hóa lỗi theo cùng một cách
    • Ngay cả thao tác không thể thất bại cũng phải được biểu diễn dưới dạng có thể thất bại
  • Trong firmware dựa trên Hubris, người ta liên tục phát hiện các thao tác thực sự không thể thất bại, và cấu hình chân GPIO là một ví dụ

Chính sách fault quyết liệt của kernel Hubris

  • Nhiều hệ điều hành sẽ trả mã lỗi hoặc cho cơ hội xử lý exception/signal ngay cả khi điều kiện tiên quyết của lời gọi hệ thống bị vi phạm
    • Trên Unix, nếu gọi close với file descriptor chưa mở thì sẽ nhận mã lỗi
    • Nếu truyền null pointer vào open thay vì pathname thì cũng sẽ nhận mã lỗi
  • Hubris sẽ hủy ngay tác vụ nếu nó phá vỡ điều kiện tiên quyết của lời gọi hệ thống
    • Tác vụ đó không thể thực thi thêm lệnh nào nữa
    • Bản thân tác vụ không có cơ hội phục hồi hay tiếp tục
    • Tác vụ supervisor của ứng dụng sẽ được báo fault, và thường sẽ xóa rồi khởi động lại tác vụ đó
  • Fault do kernel tạo ra là synthetic fault
    • Nó tương tự hardware fault do CPU tạo ra như dereference null pointer hay chia cho 0
    • Hardware fault sinh ra từ việc vi phạm quy tắc kiến trúc bộ xử lý, còn synthetic fault sinh ra từ việc vi phạm quy tắc của kernel
  • Ví dụ, trong lời gọi SEND, nếu chỉ số tác vụ nhận nằm ngoài phạm vi của ứng dụng, hoặc con trỏ thông điệp trỏ vào vùng nhớ không có quyền truy cập, thì sẽ phát sinh synthetic fault
  • Hubris không cho phép fault có thể phục hồi hoặc tiếp tục thực thi
    • Dù là hardware fault hay synthetic fault, tác vụ nhận fault đều chuyển sang trạng thái đã chết
    • Lựa chọn này nhằm tránh các chế độ lỗi tinh vi và đơn giản hóa việc suy luận về hệ thống

Cách máy chủ phản hồi client bằng fault

  • REPLY_FAULT là lời gọi hệ thống cho phép máy chủ gửi fault đến client thay cho phản hồi bình thường
  • Luồng REPLY thông thường diễn ra như sau
    • Khi client dùng SEND, kernel đánh dấu tác vụ client ở trạng thái “waiting to send” đối với tác vụ nhận
    • Khi tác vụ nhận dùng RECV, client đó chuyển sang trạng thái “waiting for reply”
    • Khi server gọi REPLY, client quay lại trạng thái runnable
  • REPLY_FAULT tương tự REPLY, nhưng thay vì chuyển thông điệp và đưa tác vụ về trạng thái có thể chạy, nó gửi fault để đưa tác vụ sang trạng thái đã chết
  • Server không thể giết tùy ý bất kỳ tác vụ nào
    • REPLY_FAULT chỉ dùng được với tác vụ mà server đó đã RECV và vẫn chưa REPLY
    • Nó chỉ hoạt động với client đang chờ phản hồi từ chính server đó
  • Hubris dùng REPLY_FAULT để xử lý các lỗi sau
    • Mã thao tác sai
    • Thông điệp hỏng, bị cắt cụt, hoặc vô nghĩa
    • Trường hợp client không gửi đúng loại loaned memory cần thiết

Lỗi ứng dụng và trải nghiệm fail-fast

  • REPLY_FAULT có thể được dùng không chỉ cho lỗi định dạng IPC mà còn cho lỗi đặc thù của ứng dụng
  • IP stack của Hubris gán cổng IP cho các tác vụ một cách tĩnh
    • Nếu một tác vụ cố đụng vào cổng IP của tác vụ khác, IP stack sẽ phát fault cho tác vụ đó
  • Cách làm này giảm nhu cầu xử lý các lỗi “mang tính lý thuyết” vốn lẽ ra không nên xảy ra trong thực tế, đồng thời làm lộ nhanh việc sử dụng sai trong quá trình phát triển
  • REPLY_FAULT giống với mô hình mà vi phạm điều kiện tiên quyết của lời gọi hàm Rust thường dẫn tới panic!, và trở thành phương tiện để server gây ra một panic! xuyên tiến trình đối với tiến trình client
  • Client không cần chứa mã riêng hay phối hợp gì để hỗ trợ cơ chế này

Thiên hướng bảo mật và các hạn chế khi kiểm thử

  • Eliza Weissman mô tả Hubris là “cực kỳ thù địch với chương trình độc hại”
  • Nỗ lực khai thác thường trước hết bộc lộ dưới dạng lỗi hay lạm dụng API, nên một hệ thống xóa sạch trạng thái của thành phần hành xử sai có thể khó bị khai thác hơn
    • Giả thuyết này vẫn chưa được kiểm chứng
    • Bài viết cũng kêu gọi liên hệ nếu ai quan tâm tới việc thử khai thác Hubris
  • Nhược điểm đã được quan sát là hệ thống rất khó fuzz test
    • Một tác vụ chaos nhỏ sinh IPC và lời gọi hệ thống ngẫu nhiên đã được triển khai, nhưng gần như làm gì cũng bị reset ngay lập tức
    • Muốn hoạt động hữu ích, nó phải dựa quyết định vào một system uptime counter thay đổi theo cách có thể quan sát được mỗi lần khởi động
  • REPLY_FAULT cũng cung cấp cách để server giết client một cách ngẫu nhiên nhằm ép chaos, nhưng lựa chọn này vẫn chưa được đánh giá đầy đủ
  • Các tác vụ Hubris thông thường không cố tình tạo động các thông điệp IPC sai, nên nhìn chung chúng có thể chạy mà không cần nhận thức về sự tồn tại của REPLY_FAULT

1 bình luận

 
GN⁺ 2024-04-28
Ý kiến trên Hacker News
  • REPLY_FAULT có vẻ phù hợp khi hệ thống nhỏ và chặt chẽ, và các ứng dụng cũng chủ yếu do những người đã thiết kế toàn bộ hệ thống viết ra
    Nhưng từ góc nhìn của lập trình viên ứng dụng, việc kết nối với mã bên thứ ba qua một mô hình IPC mà dịch vụ khác có thể gửi cho tiến trình của tôi một viên đạn kết liễu bất cứ lúc nào nghe khá đáng sợ
    Tôi không tin các lập trình viên ứng dụng khác đến mức đó. Ngoài kia đầy những tài xế tệ hại và các tiến trình nền do những lập trình viên bị quản lý thúc ép tạo ra, những người có thể sẽ nhét vào hàng đống REPLY_FAULT mặc định không phù hợp chỉ để được về trước 8 giờ tối

    • Có vẻ đó là thiết kế có chủ đích, và môi trường mà Hubris nhắm tới đúng là kiểu như vậy
    • Thực ra điều này từng xảy ra trên Symbian. Máy chủ IPC có thể khiến client panic, và từ góc nhìn của lập trình viên ứng dụng không có quyền truy cập mã nguồn OS thì điều đó khá kinh khủng
      Không phải lúc nào cũng dễ hiểu hết mọi điều kiện tiên quyết, và chúng còn có thể thay đổi tùy thiết bị hay phiên bản OS
    • Giết nhanh các hành vi lệch chuẩn là một cách để giữ hệ thống chặt chẽ. Phạm vi được thiết kế vốn dĩ cũng có khả năng sẽ được giữ nhỏ
      Phạm vi thì kiểu gì cũng có xu hướng phình ra, nhưng có lẽ người ta sẽ không muốn cố nhét vào một task Hubris trong bộ điều khiển nhúng những việc đáng ra nên xử lý ở host hơn
    • Trong môi trường nhúng, có vẻ tốt hơn nếu những hiểu lầm kiểu này được xử lý ngay khi xảy ra, bất kể lỗi thuộc về ai
      Nếu server nói “client kia sai rồi” thì kernel giết client đó. Điểm cốt lõi là hai bên đã không hiểu nhau
    • Ở đây có thể xem dịch vụ là một giao diện OS. Trong một kernel đơn, nếu thực hiện lời gọi kernel sai thì việc OS giết tiến trình đó cũng là hợp lý
      Ngoài ra, “tiến trình” ở đây có thể khác với điều bạn thường hình dung. Trong Hubris, các thread đều chia sẻ cùng một không gian địa chỉ
  • REPLY_FAULT có lan truyền theo chuỗi không? Ví dụ A SEND cho B rồi chờ, B SEND cho C rồi chờ, nếu C REPLY_FAULT thì A có chết cùng B không, tôi khá tò mò
    Nếu không thì một task độc hại chỉ cần ủy quyền thử nghiệm cho task phụ là xong. Ngược lại, nếu có thì tổng thể nghe khá mong manh, dù tôi không hiểu Hubris đủ sâu
    Hơn nữa, nếu SEND có thể mang tính vòng lặp hay tương hỗ thì task thậm chí có thể vô tình tự giết chính mình. Với trường hợp B → A → B, đó còn có thể tạo động lực để không dùng REPLY_FAULT

    • Hubris dường như không được thiết kế như một hệ điều hành đa dụng. Các tiến trình được định nghĩa ngay từ lúc build
      Lý do server có thể bắn ngược lại client không phải vì bảo mật mà vì độ tin cậy. Cách nhìn ở đây là lỗi phát sinh từ bug chứ không phải từ tấn công có chủ ý, và phản ứng cực đoan của kernel giúp lập trình viên tìm ra vấn đề nhanh nhất có thể
      Tất nhiên cũng có phần giao thoa với bảo mật, và nó có thể là một lớp phòng thủ dự phòng hữu ích khi tiến trình cố làm điều mà nó không được phép làm
    • Nếu B bị fault thì A có lẽ sẽ nhận lỗi rằng server đã chết, và sẽ có cơ hội gửi lại cùng thông điệp đó cho server vừa được khởi động lại. Có lẽ không phải là sập dây chuyền
  • Hubris và trình gỡ lỗi Humility là những công nghệ mà tôi muốn đào sâu nếu có thời gian hoặc nếu phải làm một nhiệm vụ liên quan. Tiếc là lúc này thì không thể

  • Trong một hệ thống nơi một nhóm viết toàn bộ mã nguồn, thật thú vị khi cách làm “đá client ra khỏi quỹ đạo chỉ vì nó nhìn sai kiểu” có thể tăng tốc độ lặp lại khi phát triển
    Tối qua tôi đọc về algebraic effects rồi ngủ quên, sáng nay đọc bài này thấy khá thú vị. Nếu bẻ lái một chút, đây là một kernel cho phép server thực hiện những effect mà client không thể tự xử lý
    Việc tái sử dụng và kết hợp mã có lẽ sẽ khó hơn nhiều, nhưng mô hình thực thi lại đơn giản hơn rất nhiều. Với các hệ thống nhúng tĩnh thì rõ ràng đây là một đánh đổi hợp lý. Nếu cần tái sử dụng, bạn luôn có thể vendor task đó vào rồi chỉnh sửa

    • Nếu phân tách tốt giữa các lỗi có thể dự đoán, như không có file, và các lỗi bất ngờ như opcode không hợp lệ, thì ngay cả trong các chương trình thông thường khả năng tái sử dụng cũng chưa chắc bị giảm đi nhiều
      Thậm chí Unix còn có quá nhiều lỗi có thể bỏ qua, và cá nhân tôi nghĩ một phần đáng kể trong số đó lẽ ra phải tạo ra fatal signal. Khi đó chất lượng phần mềm nói chung hẳn đã tốt hơn đáng kể
      Ví dụ, gọi close() trên một file descriptor không hợp lệ là lỗi không gây chết nên thường bị bỏ qua. Nhưng trên thực tế nó rất nguy hiểm, đặc biệt trong ứng dụng đa luồng. Phần lớn thời gian, việc đóng nhầm file descriptor sẽ thất bại một cách vô hại, nhưng 1% trường hợp thì nó lại đóng mất socket ghi log, file khóa cơ sở dữ liệu, hoặc một kết nối IPC không liên quan. Và thế là tạo ra loại phần mềm bất ổn mà ai cũng ghét
  • Tôi nhớ đến câu thoại trong Errand of Mercy: “Anh sẽ biết rằng có rất nhiều quy tắc và quy định. Chúng sẽ được niêm yết. Vi phạm dù chỉ điều nhỏ nhất trong số đó cũng bị xử tử”

  • Cái này nên được viết thành một RFC Cá tháng Tư cho HTTP
    Tôi đề xuất HTTP 499 “Shame on you.”. Client nhận mã 499 này nên chấm dứt task đã phát ra request đó theo cách riêng của từng ngôn ngữ, có lẽ chỉ áp dụng với các request bắt đầu bằng một header cụ thể như Strict: true
    Nó cân bằng hoàn hảo cái cảm giác “cái quái gì vậy… nhưng mà thật ra, cũng ổn đấy?” trong bối cảnh này

  • Đọc rất thú vị, và cách làm một supervisor duy nhất này khá giống với cách một startup cũ của tôi từng cấu trúc ứng dụng để mọi thứ đều unwrap
    Nó cũng làm tôi nhớ đến một trong những bài tôi thích: https://medium.com/@mattklein123/crash-early-and-crash-often...

  • Tôi tự hỏi liệu cách này có thực sự quá hung hăng hay không
    Trên Linux, không thể trực tiếp làm sập một chương trình khác chỉ bằng cách giao tiếp qua socket, trừ trường hợp gửi dữ liệu sai vào socket
    Nhưng chắc chắn là có thể giết nó. Bất cứ thứ gì đang chạy bằng root đều có thể giết thứ khác, thậm chí còn có thể reboot để hạ toàn bộ hệ thống
    Khó hơn một chút và ít phổ biến hơn, nhưng ít nhất trong container thì quyền root lại khá thường thấy. Tất nhiên vẫn có cgroup nên bị giới hạn hơn, nhưng ý chính là vậy
    Điều này cũng hơi khác với sự khôn ngoan thông thường kiểu “hãy rộng lượng với những gì nhận vào, và bảo thủ với những gì gửi ra”. Dù vậy, có lẽ câu đó gắn với các hệ thống mạng nhiều hơn
    Nhưng có lẽ việc hệ thống phải rộng lượng với những gì nó chấp nhận là điều khó tránh. Nếu không thì làm sao có thể thay đổi nhẹ API mà không làm hỏng các chương trình hiện có?

    • Hubris không phải OS đa dụng mà chạy trên các bộ xử lý mức thấp bên trong rack server Oxide
      Theo tôi biết thì nó cũng không cho phép thêm kiểu process mới ở runtime. Mọi executable có thể chạy đều phải được xác định từ lúc compile
  • Với đoạn “không có cách sửa vấn đề rồi tiếp tục task. Đây là một lựa chọn có chủ đích để tránh các chế độ lỗi tinh vi và đơn giản hóa việc suy luận về hệ thống”, tôi nhớ đến câu nổi tiếng của Einstein: “hãy làm mọi thứ đơn giản nhất có thể, nhưng không đơn giản hơn nữa”
    Thiết kế này có vẻ đã vi phạm vế sau. Tôi không hứng thú với môi trường vận hành không thể chịu nổi chút hỗn loạn nào của thế giới thực, và cũng không rõ trong những lĩnh vực khả thi về mặt thương mại thì có nơi nào chấp nhận điều đó
    Rốt cuộc là quay lại hệ thống init để nó cứ retry liên tục sao? Nhưng bằng cơ chế nào nó có thể hiểu được fault đã xảy ra để thử lại theo cách tốt hơn?
    Dù sao thì tôi vẫn vỗ tay cho sự thuần khiết trong niềm tin này

    • Hubris không phải một thử nghiệm học thuật. Nó chạy ở trung tâm của mọi thành phần cốt lõi trong rack Oxide — tức compute sled, switch và bộ điều khiển power shelf — và thiết kế của nó trước hết dựa trên giá trị thực tế mà nó mang lại
      Thực tế, như Cliff đã viết khá chi tiết trên blog, REPLY_FAULT ban đầu là một tính năng mà chúng tôi nghĩ có thể hơi quá hung hăng, nhưng kinh nghiệm thu được khi xây dựng, triển khai và, thành thật mà nói, debug hệ thống đã khiến chúng tôi tin rằng nó không làm hệ thống hỏng hóc thất thường mà còn khiến nó vững chắc hơn
      Có thể xem thêm về tư duy và hình dạng thực tế của nó tại [0] và [1]
      [0] https://www.mattkeeter.com/blog/2024-03-25-packing/
      [1] https://cliffle.com/blog/who-killed-the-network-switch/
    • Watchdog timer sẵn sàng giết hoặc khởi động lại các process không được “đá” định kỳ
      Ngay cả trong các dự án sở thích, tôi cũng từng thấy bus I2C thường xuyên bị treo khi một bit giao thức nào đó bị lệch, rồi kéo sập cả hệ thống, nên tôi thấy thiết kế này khá truyền cảm hứng
      Theo cách tôi hiểu thì đây là câu chuyện về những trường hợp lỗi đã biết — tức không phải lỗi có thể xử lý — mà là các sai lệch giao thức và những điều tuyệt đối không được phép xảy ra
      Như các bình luận khác cũng đã chỉ ra, đây là một OS được thiết kế riêng theo mục đích. Cũng như bạn sẽ không làm UI bằng Erlang, Hubris có vẻ rất phù hợp với không gian mà nó chiếm giữ
    • Tôi nghĩ đây rõ ràng là một ý tưởng được áp dụng cho các vấn đề là kết quả của trạng thái chương trình sai. Vì thế không thể phục hồi một cách hợp lý
      Nguyên nhân có thể là bug, tấn công hoặc phần cứng bị lỗi, và trong bất kỳ trường hợp nào cũng không nên tiếp tục. Phía caller đang có vấn đề nghiêm trọng, và nếu tiếp tục thì chỉ gây thêm thiệt hại lớn hơn
      Nghe hơi giống triết lý “let it crash” của Erlang/OTP. Erlang được dùng trong khá nhiều phần cứng mission-critical và nổi tiếng về độ tin cậy, nên trên thực tế đây có thể không phải một điểm trừ lớn đến vậy
    • Đây là một kernel hệ thống nhúng Rust dài 2000 dòng không hỗ trợ thêm task mới ở runtime
      Nó được viết để chạy sâu bên trong các rack server của 0xide
  • Với đoạn “các nỗ lực khai thác thường bộc lộ trước dưới dạng lỗi API hoặc sử dụng sai, nên một hệ thống xóa trạng thái của component có hành vi sai cho mọi kiểu trục trặc hẳn sẽ khó bị khai thác hơn”, ở đây về cơ bản là ứng dụng đang kiểm tra nghiêm ngặt hơn một chút những gì nó chấp nhận
    Vì thế có lợi ích về bảo mật, nhưng khác với kiểu người ta thường nghĩ đến. Nó không phải phá hủy tiến độ của kẻ tấn công để đẩy lùi chúng, mà là khiến một số trạng thái sai cụ thể — vốn trước đây có thể bị nối tiếp thành trạng thái sai như mong muốn hơn — không còn hoạt động nữa
    Khi đó, thay vì tiếp tục thử cách đó, kẻ tấn công sẽ đi tìm chỗ khác