2 điểm bởi GN⁺ 2023-09-11 | 1 bình luận | Chia sẻ qua WhatsApp
  • NAT IPv4 thường phân biệt đích nhận phản hồi bằng cổng TCP/UDP, nhưng ICMP echo của ping không có cổng, nên điểm mấu chốt là Linux dùng giá trị nào làm khóa ánh xạ
  • Thí nghiệm tái hiện cấu hình NAT từ 192.168.99.0/24 sang 10.0.100.0/24 bằng cách tạo client1, client2, natbox, server với network namespaceiptables MASQUERADE
  • Khi đối chiếu RFC 792 với gói tin bắt được, có thể thấy Identifier và Sequence Number của ICMP echo được dùng để ghép cặp request/reply; trên đường đi ICMP SOCK_DGRAM của Linux, local port của socket sẽ đi vào trường ID
  • Nếu hai client cùng dùng ICMP ID 999, netfilter sẽ đổi ICMP ID của một bên sang giá trị ngẫu nhiên để tránh xung đột, rồi trong phản hồi sẽ đổi lại về IP và ID gốc của client
  • NAT của Linux, ngay cả với ICMP không có cổng, vẫn lưu trạng thái hướng đi/hướng về trong conntrack tuple và dùng ICMP ID như khóa có thể thao tác để ánh xạ phản hồi về đúng host nội bộ

Môi trường thí nghiệm và cấu hình NAT

  • Để mô phỏng nhiều thiết bị trên một máy Linux, bài viết dùng network namespace
  • Tạo riêng từng namespace cho hai client, natbox đóng vai trò router NAT, và server; đồng thời tách mạng riêng với mạng phía server
    • client1: 192.168.99.1/24
    • client2: 192.168.99.2/24
    • Giao diện trong của natbox: 192.168.99.3/24
    • Giao diện ngoài của natbox: 10.0.100.1/24
    • server: 10.0.100.2/24
  • Chạy ip, iptables, tcpdump... với quyền root trên Fedora 38 Server VM và Linux kernel 6.2.9
  • Hai client được nối vào bridge br0, còn natbox được nối lần lượt tới bridge và veth pair phía server
  • Default route của client được đặt là 192.168.99.3 để lưu lượng đi tới server phải đi qua natbox
  • Trên natbox, bật chuyển tiếp gói bằng net.ipv4.ip_forward=1 và thêm quy tắc MASQUERADE vào chain POSTROUTING của bảng nat trong iptables
    • ip netns exec natbox iptables -t nat -A POSTROUTING -o eth1 -j MASQUERADE

NAT ICMP qua quan sát gói tin

  • Dùng tcpdump -n icmp trong namespace client1server để bắt gói ICMP
  • Ở phía client thấy echo request 192.168.99.1 > 10.0.100.2 và echo reply 10.0.100.2 > 192.168.99.1
  • Ở phía server, IP nguồn của cùng request đã đổi thành 10.0.100.1, xác nhận NAT đã viết lại địa chỉ nguồn thành IP ngoài của natbox
  • Các request ICMP từ những client khác nhau có trường id khác nhau
    • Trong ví dụ, client1 có ID 31428
    • client2 có ID 33391
  • Quan sát này cho thấy natbox có thể dùng trường ID khi chuyển phản hồi ICMP trở lại client nội bộ

RFC 792 và ICMP ID của ping

  • ICMP là giao thức cũ được định nghĩa trong RFC 792, công bố từ năm 1981
  • Bản tin ICMP echo và echo reply có Type, Code, Checksum, Identifier, Sequence Number, Data
  • Type dùng để phân biệt echo request và echo reply
    • Type của echo request là 8
    • Trong phần trích RFC, echo reply được ghi là 1
    • Code là 0
  • RFC 792 giải thích rằng Identifier và Sequence Number có thể được dùng để ghép cặp echo request với reply
  • Identifier có thể được dùng để nhận diện phiên giống như cổng trong TCP/UDP, còn Sequence Number có thể tăng dần theo mỗi echo request
  • RFC không quy định cụ thể cách chọn ID trong thực tế, nên cần xem mã nguồn của ping là triển khai cụ thể

Cách iputils ping quyết định ID

  • Lệnh ping thuộc gói iputils
  • Chú thích gần ping4_send_probe nói rằng khi tạo ICMP echo request, trường ID là một số ngẫu nhiên còn Sequence Number là số nguyên tăng dần
  • Bên trong ping có trường ident của struct ping_rts
    • Giá trị mặc định là -1
    • Có thể ghi đè bằng tùy chọn CLI -e với giá trị từ 0 đến IDENTIFIER_MAX0xFFFF
  • Nếu rts->ident == -1, ping sẽ bind socket với kiểu SOCK_DGRAM và giao thức IPPROTO_ICMP
  • Theo mô tả về socket IPPROTO_ICMP trên Linux, header ICMP sẽ được kiểm tra và dọn dẹp khi send(), còn id sẽ được đặt theo local port của socket
  • Nếu ping không chỉ định source port, Linux kernel sẽ chọn ngẫu nhiên một cổng còn trống, và cổng đó được dùng làm ID của gói ICMP

Khi cùng một ICMP ID bị xung đột

  • Trên hai client, dùng ping -e 999 để gửi ping tới server với cùng ICMP ID 999
  • Kết quả bắt gói ở server cho thấy request của một client vẫn giữ ID 999, còn request của client kia bị đổi thành ID 30218
  • Thiết bị NAT đổi ID của một bên để tránh xung đột tổ hợp cùng IP ngoài và cùng ICMP ID
  • Để tìm nơi xử lý xung đột, bài viết kiểm tra mã trong thư mục net/netfilter của Linux nơi sử dụng trường ICMP id

Vai trò của netfilter, conntrack và NAT

  • Hệ thống con trong kernel thực thi các quy tắc iptablesnetfilter
  • Vì quy tắc MASQUERADE thực hiện NAT, nên phần triển khai NAT cho ICMP cũng nằm trong netfilter
  • nf_nat_setup_info trong nf_nat_core.c gọi get_unique_tuple, rồi tiếp tục tới nf_nat_l4proto_unique_tuple
  • nf_nat_l4proto_unique_tuple có nhánh IPPROTO_ICMP và tham chiếu tuple->src.u.icmp.id
  • nf_nat_manip_pkt trong nf_nat_proto.c đi qua nf_nat_ipv4_manip_pktl4proto_manip_pkt, rồi khi là ICMP sẽ gọi icmp_manip_pkt
  • icmp_manip_pkt ghi ICMP ID thực tế vào gói bằng hdr->un.echo.id = tuple->src.u.icmp.id

Cách conntrack tuple biểu diễn ICMP

  • Trong netfilter, connection không chỉ có nghĩa là kết nối TCP mà còn là trạng thái dùng để ghép gói đi và gói về cho cả các giao thức không kết nối như UDP hay ICMP
  • nf_conntuplehash[IP_CT_DIR_MAX]
    • IP_CT_DIR_ORIGINAL: hướng gói đi ra
    • IP_CT_DIR_REPLY: hướng phản hồi đi vào
  • Mỗi nf_conntrack_tuple_hash chứa nf_conntrack_tuple dùng để nhận diện connection
  • Tuple được chia thành src có thể thao tác và dst bất biến
    • src chứa địa chỉ IP và trường riêng theo giao thức
    • Với ICMP, trường riêng theo giao thức là __be16 id
    • dst chứa địa chỉ IP không thay đổi cùng ICMP type, code
  • NAT lưu trong connection cách nó đã biến đổi gói đi, rồi đảo ngược thay đổi đó trên gói phản hồi

Đường đi mã chọn ICMP ID

  • Khi natbox nhận ICMP echo, nf_nat_setup_info tạo connection mới và quyết định có cần đổi IP nguồn cùng ICMP ID hay không
  • Sau đó, với mỗi gói ICMP, nf_nat_manip_pkt sẽ đặt IP nguồn hoặc đích và ICMP ID dựa trên giá trị đã lưu trong connection
  • get_unique_tuple là đường đi chính để chọn một NAT tuple còn dùng được
    • find_best_ips_proto viết lại địa chỉ IP nguồn
    • nf_nat_used_tuple kiểm tra tuple đã được dùng hay chưa; nếu chưa thì trả lại nguyên tuple hiện tại
    • Vì vậy, nếu ICMP ID của hai client khác nhau thì ID vẫn được giữ nguyên sau NAT
    • Nếu tuple đã được dùng, nf_nat_l4proto_unique_tuple sẽ được gọi để thực hiện NAT theo từng giao thức
  • Với ICMP, tuple->src.u.icmp.id được chọn làm khóa NAT đích
  • find_free_id tạo ID ngẫu nhiên bằng get_random_u16(), điều chỉnh về phạm vi ICMP ID hợp lệ rồi kiểm tra xem đã dùng chưa
  • Phạm vi ID mặc định là toàn bộ dải ID; trong quy tắc iptables MASQUERADE có thể chỉ định phạm vi như 100-200 bằng --to-ports
  • Nếu không tìm được tuple chưa dùng, ID trùng vẫn sẽ ở lại trong connection, và sau đó __nf_conntrack_confirm phát hiện trùng lặp rồi drop gói

Xác minh hành vi kernel bằng bpftrace

  • Để kiểm tra cách hiểu về netfilter, bài viết dùng bpftrace
  • Các hàm kernel được theo dõi là nf_nat_setup_infonf_nat_manip_pkt
  • kprobe theo dõi thời điểm hàm được gọi, còn kretprobe theo dõi thời điểm hàm trả về
  • Trong kretprobe không thể truy cập trực tiếp tham số hàm, nên cần lưu tham số vào BPF map lúc vào hàm rồi đọc lại khi trả về
  • struct sk_buff là cấu trúc Linux kernel dùng để biểu diễn gói tin
  • bswap được dùng để đổi network byte order dạng big endian sang little endian
  • ntop chuyển địa chỉ IP thành chuỗi
  • Nhờ BPF Type Format (BTF) trên các kernel Linux mới, chương trình BPF có thể tham chiếu các cấu trúc kernel như sk_buff, nf_conn mà không cần include header
  • Chương trình bpftrace này được thử trên Linux kernel 6.2.9; ở các phiên bản kernel khác, khả năng hoạt động có thể khác

Kết quả theo dõi và kết luận

  • Khi hai client gửi ping với cùng ICMP ID 999, nf_nat_setup_info được gọi một lần cho mỗi client
  • Client đầu tiên 192.168.99.1 giữ nguyên ICMP ID 999 ở cả original tuple lẫn reply tuple
  • Client thứ hai 192.168.99.2 bị viết lại ICMP ID của reply tuple thành 32809
  • nf_nat_manip_pkt đổi IP nguồn thành 10.0.100.1 với NF_NAT_MANIP_SRC trên echo request, và trên phản hồi thì đổi IP đích về IP client gốc với NF_NAT_MANIP_DST
  • ICMP ID trong gói phản hồi cũng được khôi phục từ giá trị đã NAT về lại giá trị gốc mà client đã gửi
  • Có thể kiểm tra timeout conntrack ICMP mặc định tại /proc/sys/net/netfilter/nf_conntrack_icmp_timeout, và giá trị mặc định quan sát được là 30 giây
  • Nếu client không gửi gói nào trong hơn 30 giây, nf_nat_setup_info sẽ được gọi lại ở lần ping tiếp theo
  • Hành vi NAT của Linux với ping cũng được tài liệu hóa trong Netfilter Hacking HOWTO, và điểm cốt lõi là conntrack tuple cùng việc viết lại ICMP ID

1 bình luận

 
GN⁺ 2023-09-11
Các ý kiến trên Hacker News
  • Có thể bạn sẽ quan tâm đến https://samy.pl/pwnat/
    Khi máy chủ khởi động, nó bắt đầu gửi các gói ICMP echo request cố định đến địa chỉ cố định 3.3.3.3, và kỳ vọng rằng các gói này sẽ không quay trở lại
    3.3.3.3 không phải là một host có thể truy cập được, cũng không phải là mục tiêu để spoof. Thay vào đó, khi client muốn kết nối, vì nó biết IP của server nên nó gửi cho server một gói ICMP Time Exceeded. Bên trong gói ICMP đó có gói cố định “gốc” mà server vẫn gửi đến 3.3.3.3, và gói được hard-code này đóng vai trò là định danh của pwnat
    Có thể hiểu là client giả vờ là một hop trên Internet và báo rằng “ICMP echo request” ban đầu của server không thể được chuyển tiếp. NAT thấy gói bên trong ICMP Time Exceeded khớp với gói mà server đã gửi nên chuyển tiếp nó đến server phía sau NAT; lúc này toàn bộ IP header của client cũng được bao gồm, nhờ đó server biết được địa chỉ IP của client
    • Tóm lại, mẹo ping 3.3.3.3 là cách để server phía sau NAT biết địa chỉ IP của client phía sau NAT, và không cần một server không nằm sau NAT như https://ifconfig.co
      Hoạt động cốt lõi của công cụ này sau đó là tạo một UDP tunnel giữa client và server
      Tuy nhiên, theo tôi lướt qua thì có vẻ nó giả định rằng NAT không viết lại cổng nguồn UDP, nên có lẽ không hoạt động trên mọi router. STUN, dùng trong WebRTC và các trường hợp tương tự, triển khai các kỹ thuật tinh vi hơn, nhưng nếu vẫn không được thì phải dùng TURN làm relay
      Rất có khả năng cùng vấn đề này cũng áp dụng cho mẹo ping 3.3.3.3. Nếu NAT viết lại định danh ping như trong bài viết, mẹo này sẽ hỏng
  • Khi một thiết bị trong mạng cục bộ gửi ping đến một thiết bị trên Internet, router thực hiện NAT sẽ đổi địa chỉ nguồn của ping thành IP công khai của chính nó và viết lại trường ID của gói ICMP thành một giá trị duy nhất
    Khi nhận được phản hồi, router dùng giá trị ID duy nhất đó để chuyển phản hồi đến đúng thiết bị trong mạng cục bộ
    • Để nhìn nhận đúng hơn, hãy nghĩ về cách hệ điều hành phân biệt các cuộc hội thoại ICMP khác nhau cùng hướng tới một đích
      Chỉ cần một máy tính và Wireshark/tcpdump là có thể kiểm chứng
      Bản thân bài viết thì hay, và có thể đem lại khoảnh khắc “vỡ ra” cho những ai hoàn toàn chưa hiểu về networking. Tuy vậy, về bản chất nó giống cách dựng một phòng lab mạng tử tế rồi đào vào mã nguồn hơn là tự suy luận trực tiếp
    • Đẩy suy nghĩ này thêm một chút, về cơ bản là biến một giao thức phi trạng thái thành giao thức có trạng thái
    • Ping dù sao cũng cần thông tin trạng thái như vậy để ghép yêu cầu với phản hồi
    • Tôi thắc mắc vì sao không dùng IP riêng nguồn thay cho “giá trị duy nhất”
    • Tôi thắc mắc ID đó nằm trong ICMP header, hay thuộc về phần IP
  • Thật vui khi thấy một bài kiểu “nó hoạt động thế nào” đi xuống theo các tầng trừu tượng và đào tới tận mã nguồn. Phần giải thích tốt và có nhiều thông tin
    • Tôi cũng định vào để nói điều này. Routing và networking với tôi vẫn còn rối, còn các bài liên quan thường cho cảm giác quá trừu tượng
      Những ví dụ có thể tự tay làm theo kiểu này thật sự rất đáng quý, và tôi định sẽ tự làm theo
      Bài viết gần như duy nhất khác về chủ đề này mà tôi thấy dễ hiểu là bài này của Tailscale. Có nhiều “ví dụ diễn giải chi tiết”, nên thấy rõ toàn bộ các phần khớp với nhau ra sao
      https://tailscale.com/blog/how-nat-traversal-works/
  • Bài viết hay
    Tình cờ là cuối tuần này tôi cũng vật lộn với Netfilter để bật transparent proxy trên router OpenWRT
    Các tài liệu cơ bản nên tham khảo khi xem Netfilter là https://wiki.nftables.org/wiki-nftables/index.php/Main_Pagehttps://www.netfilter.org/projects/nftables/manpage.html
  • ICMP không có cổng, nên NAT không cần xử lý vấn đề đưa ICMP echo reply về đúng cổng
    Nhưng ICMP echo request có ID, và trên thực tế nó đóng vai trò giống như số cổng nguồn
    Để NAT ICMP echo đúng cách, cũng như remap cổng nguồn của UDP, ID cũng cần được remap hai chiều
    Vì nếu các máy phía sau NAT đồng thời nhận ping từ hai host, và hai host tình cờ dùng cùng số yêu cầu, thì sẽ trở nên mơ hồ
    Một khả năng khác là không viết lại định danh mà duy trì danh sách các máy từ xa gắn với từng ID. Nếu ID bị trùng, danh sách sẽ có hơn một địa chỉ IP từ xa; khi máy phía sau NAT trả lời, NAT có thể chọn một máy trong danh sách, gửi phản hồi đến máy đó rồi xóa mục tương ứng
  • NAT thật sự là một lớp trừu tượng bẩn thỉu. IPv4 nên biến mất
    • Internet ở nhà tôi có các thiết bị nằm trên nhiều subnet trong dải 192.168. Gần đây khi tôi đổi ISP, AS mà nhà tôi thuộc về thay đổi và tôi nhận địa chỉ IPv4 mới, nhưng chỉ cần cập nhật việc chuyển tiếp lưu lượng đi vào IP mới trên WAN router là xong
      Nếu là IPv6, tôi đã phải đổi mọi node trong mạng và cập nhật cả DNS nội bộ
      Về lý thuyết tôi có thể có một /48 mang theo được, nhưng ISP mới phải quảng bá nó giúp tôi, và dù ISP hiện tại có làm thì đó cũng không phải chuyện phổ biến
      Một tuần trước khi đường dây điện thoại bị đứt, tôi lấy 5G MiFi ra và chuyển kết nối WAN sang đó; chỉ cần bật masquerade đơn giản trên interface đó là được. Tín hiệu yếu nên không tốt lắm, nhưng vẫn hoạt động
      Vấn đề là dù dùng IPv6, tôi vẫn phải vận hành dual-stack hoặc dùng lớp trừu tượng NAT bẩn thỉu. Chỉ làm tăng việc mà không đem lại lợi ích cho tôi
      Phía công việc cũng tương tự. Các xe dùng các subnet nội bộ 172.16/12 kết nối và định tuyến lẫn nhau, đồng thời nối ra bên ngoài qua nhiều kết nối VPN khác nhau. Chúng thường đậu dưới tầng hầm nên gần như không có tín hiệu, vì vậy kiến trúc là hy vọng ít nhất một trong nhiều cách sẽ hoạt động

Khi chuyển sang IPv6 thì lại phải di chuyển các khối /48. Hơn nữa, những xe này nhận Internet tại nhiều sân vận động khác nhau, mà nhiều nơi trong số đó thậm chí còn khó tắt MITM/443 hoặc gỡ chặn UDP. Trong môi trường đến nơi lúc 10 giờ sáng thứ Bảy và phải chạy được sau 2 giờ thì cách đó không khả thi
Không rõ lợi ích kinh doanh của việc chuyển sang dual-stack là gì, khi nó làm khối lượng công việc và rủi ro tăng gấp đôi

  • Không chắc IPv6 sẽ giải quyết vấn đề này. Về mặt kỹ thuật thì có, nhưng các nhà cung cấp lớn đã chỉ cấp /64 cho người dùng gia đình và tính phí rất cao cho /48 “dành cho doanh nghiệp”, khiến mọi thứ đang dẫn tới NAT IPv6 hoặc chia nhỏ thêm /64. Đáng lẽ không nên làm như vậy
  • Khi đó tôi sẽ càng ghét CG-NAT hơn
  • Cần lưu ý đến hiệu ứng Lindy. Đây là quan sát rằng tuổi thọ tương lai của những thứ không bị tiêu hao, như công nghệ hay ý tưởng, tỷ lệ với tuổi hiện tại của chúng; IPv4 đã tồn tại lâu như vậy nên nhiều khả năng sẽ còn ở lại khá lâu nữa
    https://en.wikipedia.org/wiki/Lindy_effect
  • IPv6 cũng nên biến mất. Nó đã có quá đủ thời gian để trở nên áp đảo, nhưng vẫn cứ ì ạch
  • Tôi tự hỏi liệu có thể lạm dụng ping để xử lý xuyên NAT mà không cần máy chủ trung tâm, đồng thời gửi các thông điệp ngắn trong mạng P2P dựa trên UDP hay không. Phần thông điệp có vẻ như đã có người tìm ra rồi
    https://stackoverflow.com/questions/31857419/how-to-send-a-m...
    Đáng tiếc là ping được hệ điều hành xử lý, nên các ứng dụng ở IP ngang hàng không thể đọc thông điệp
    Tôi nghĩ đã đến lúc cung cấp hook ở user space cho một số dịch vụ kiểu này để cho phép P2P thật sự khi cả hai bên đều ở sau NAT. Ít nhất cũng là một luồng sự kiện chỉ đọc chẳng hạn. Giờ đây, mọi rào cản ngăn điều đó đều có vẻ nhân tạo
    • Chỉnh kỹ thuật nhỏ: ping không phải UDP mà là ICMP
      Tuy vậy, tôi đã từng thấy các chiến lược rò rỉ dữ liệu dùng ping hoặc các cách liên lạc khác. Ngày nay, đa số cấu hình mặc định của tường lửa âm thầm bỏ toàn bộ ICMP, bao gồm ping, nên có lẽ gần như không thể dùng cho P2P
    • Ý tưởng thú vị. id thực chất trông tương đương với (sport, dport), nhưng chỉ 16 bit nên không gian nhỏ hơn nhiều so với 32 bit
      Nhưng tôi nghĩ vấn đề cốt lõi của NAT hole punching là muốn tạo kết nối thì cả hai đầu đều phải có hoạt động. Vì vậy luôn cần một máy chủ điều phối để báo cho T biết rằng nút S muốn nói chuyện với nó
      Dù vậy cũng có thêm điều để suy nghĩ. Tôi tự hỏi liệu có cách nào dùng các thông điệp định tuyến ICMP, chẳng hạn unreachable hoặc TTL expired, hay không. Khi traceroute tới một IP nào đó, bạn nhận lại gói tin từ các IP tùy ý khác, và việc này nhìn chung đi qua NAT
      Có thể tưởng tượng host T muốn nhận kết nối đến chọn ngẫu nhiên một địa chỉ IP “giả”, công bố (IP router, IP giả) làm định danh của mình, rồi định kỳ gửi gói tin tới IP giả đó. Host S muốn nói chuyện với T có thể gửi ICMP TTL-expired liên quan đến địa chỉ giả đó tới router của T, và router có thể thấy nó rồi chuyển tiếp cho T
      Tất nhiên điều này phụ thuộc vào việc địa chỉ IP bên trong các trường ICMP có bị lọc ingress như địa chỉ trong IP header hay không
      Sửa: đã có một bình luận cấp cao trỏ tới phần triển khai của ý tưởng này
    • Đã có rồi: https://samy.pl/pwnat/
    • Không đúng hẳn thứ tôi đang tìm, nhưng nói đến việc lạm dụng ping làm tôi nhớ tới pingfs. Nó đem lại một định nghĩa hoàn toàn mới cho điện toán đám mây
      [1] - https://github.com/yarrick/pingfs
    • Khi mức độ chấp nhận IPv6 tăng hơn nữa, mọi người sẽ có IP có thể định tuyến công khai và có thể tránh NAT hoàn toàn, nên các vấn đề kiểu này sẽ giảm đi
  • Khi viết các bài blog kiểu này, thật bực mình vì quá khó để liên kết tới một dòng code cụ thể và giữ cho liên kết đó còn sống cũng như vẫn hữu ích theo thời gian
    Nếu là GitHub thì có thể gắn với tổ hợp commit hash cụ thể, tên tệp và số dòng, nhưng khi codebase thay đổi lớn thì nó không còn hữu ích mấy. Trên các trình xem web cho git ít dùng hơn như git.blender.org thì việc này không hoạt động tốt
    • Với mã nguồn kernel Linux, dùng elixir thì ít nhất có thể liên kết tới một phiên bản cụ thể. Nếu muốn có độ bền ở mức nào đó thì dùng phiên bản LTS là được
      https://elixir.bootlin.com/linux/latest/source
  • Tóm lại, bên trong gói ICMP có một trường id, và Netfilter nhận diện gói hoặc frame ICMP như một “trường hợp đặc biệt”