Khi lệnh ping quyết định "phản công": Điều gì thực sự xảy ra với đồng hồ hệ thống?
Bài viết khám phá sự cố thú vị khi lệnh ping hiển thị thông báo lạ về việc "thực hiện các biện pháp đối phó" do đồng hồ hệ thống bị điều chỉnh ngược. Tác giả đi sâu vào mã nguồn, cơ chế timestamp của Linux và sử dụng các kỹ thuật debug như strace để giải mã hành vi này.

Khi lệnh ping quyết định "phản công": Điều gì thực sự xảy ra với đồng hồ hệ thống?
Sau một kỳ nghỉ dài, tôi bất đắc dĩ phải quay lại với thế giới thực. Tôi mở chiếc máy tính xách tay công ty ra, hơi lo sợ khi phải kiểm tra hộp thư email. Tuy nhiên, trước khi mở trình duyệt, tất nhiên là tôi phải chạy lệnh ping trước. Debug mạng là bước bắt buộc sau khi khởi động máy, đúng không? Như dự đoán, mạng hoạt động hoàn toàn tốt, nhưng điều khiến tôi ngạc nhiên là thông báo sau đây:
Thông báo cảnh báo của ping về việc thực hiện các biện pháp đối phó
Tôi không hề mong đợi ping lại "thực hiện các biện pháp đối phó" (taking countermeasures) sớm như vậy. Thật sự là tôi không mong đợi bất kỳ biện pháp đối phó nào vào thứ Hai đó!
Sau khi vượt qua sự bối rối ban đầu, tôi hít một hơi thật sâu và bình tĩnh suy nghĩ. Bạn không cần phải là Sherlock Holmes để hiểu chuyện gì đã xảy ra. Tôi đã quá nhanh tay - tôi chạy lệnh ping trước khi trình nền NTP (Network Time Protocol) của hệ thống đồng bộ hóa giờ xong. Trong trường hợp của tôi, đồng hồ máy tính đã bị quay ngược lại, khiến ping bị nhầm lẫn.
Mặc dù việc này không xảy ra quá thường xuyên, nhưng đồng hồ máy tính hoàn toàn có thể được điều chỉnh tiến hoặc lùi. Tuy nhiên, khá hiếm khi một tiện ích mạng thông thường như ping lại cố gắng xử lý tình huống này. Rất ít người dùng thuật ngữ "thực hiện các biện pháp đối phó" cho việc này. Tôi hoàn toàn mong đợi ping chỉ cần in ra một giá trị thời gian vô nghĩa và tiếp tục chạy.
Rõ ràng các nhà phát triển của ping đã dành chút suy nghĩ cho trường hợp này. Tôi tự hỏi họ đã đi xa đến đâu. Họ có xử lý việc thay đổi đồng hồ theo cả hai hướng không? Các phép đo sai có bị loại khỏi thống kê cuối cùng không? Họ kiểm thử phần mềm như thế nào?
Tôi không thể lờ đi việc ping "đối phó" với mình như vậy. Bây giờ tôi phải hiểu ping đã làm gì và tại sao.
Hiểu về Ping
Một cuộc điều tra như thế này bắt đầu bằng cái nhìn nhanh vào mã nguồn:
Ping có lịch sử khá lâu đời. Nó ban đầu được viết bởi Mike Muuss khi đang làm việc tại Phòng thí nghiệm Nghiên cứu Đạn đạo Qu đội Hoa Kỳ vào năm 1983, trước cả lúc tôi sinh ra. Đoạn mã chúng ta cần tìm nằm trong hàm gather_statistics() trong file iputils/ping/ping_common.c:
Đoạn mã khá đơn giản: thông báo trên được in ra khi độ trễ khứ hồi (RTT) đo được là số âm. Trong trường hợp này, ping sẽ đặt lại phép đo độ trễ về 0. Đó là tất cả: "thực hiện các biện pháp đối phó" chẳng qua gì hơn là đánh dấu phép đo lỗi là 0ms.
Nhưng chính xác thì ping đo cái gì? Có phải là đồng hồ tường (wall clock)? Trang man (hướng dẫn) sẽ giải cứu chúng ta. Ping có hai chế độ.
Chế độ "cũ", chế độ -U, trong đó nó sử dụng đồng hồ tường. Chế độ này kém chính xác hơn (có độ rung jitter cao hơn). Nó gọi gettimeofday trước khi gửi và sau khi nhận gói tin.
Chế độ "mới", mặc định, trong đó nó sử dụng "thời gian mạng". Nó gọi gettimeofday trước khi gửi và lấy timestamp nhận từ SO_TIMESTAMP CMSG chính xác hơn. Chúng ta sẽ nói kỹ hơn về điều này sau.
Theo dõi gettimeofday là khó
Hãy bắt đầu với công cụ cũ kỹ strace:
$ strace -e trace=gettimeofday,time,clock_gettime -f ping -n -c1 1.1 >/dev/null
... nil ...
Nó không hiển thị bất kỳ cuộc gọi nào đến gettimeofday. Chuyện gì đang xảy ra vậy?
Trên Linux hiện đại, một số syscall không phải là syscall thật. Thay vì nhảy đến không gian kernel (chậm), chúng nằm lại trong không gian người dùng (userspace) và đi đến một trang mã đặc biệt được cung cấp bởi kernel máy chủ. Trang mã này được gọi là vdso. Nó hiển thị với chương trình như một thư viện .so:
Các cuộc gọi đến vùng vdso không phải là syscall, chúng nằm trong userspace và siêu nhanh, nhưng strace cổ điển không thể nhìn thấy chúng. Để debug, thật tuyệt nếu có thể tắt vdso và quay lại các syscall chậm cổ điển. Nói dễ hơn làm.
Không có cách nào để ngăn chặn việc tải vdso. Tuy nhiên, có hai cách để thuyết phục một chương trình đã tải không sử dụng nó.
Kỹ thuật đầu tiên là lừa glibc tin rằng vdso không được tải. Trường hợp này phải được xử lý để tương thích với Linux cổ đại. Khi khởi tạo trong một quy trình mới chạy, glibc kiểm tra Vector Phụ (Auxiliary Vector) được cung cấp bởi bộ nạp ELF. Một trong các tham số có vị trí con trỏ vdso.
Một kỹ thuật được đề xuất trên Stack Overflow hoạt động như sau: chúng ta sẽ hook vào một chương trình trước khi execve() thoát ra và ghi đè tham số AT_SYSINFO_EHDR của Auxiliary Vector. Tuy nhiên, mã được liên kết không hoạt động hoàn toàn với tôi và có một lỗi cơ bản lớn hơn. Để hook vào execve(), nó sử dụng ptrace(), do đó nó sẽ không hoạt động dưới strace của chúng ta!
Mặc dù kỹ thuật ghi đè AT_SYSINFO_EHDR khá thú vị, nó sẽ không hoạt động cho chúng ta. (Tôi tự hỏi có cách nào khác để làm điều này không, nhưng mà không dùng ptrace. Có lẽ với BPF? Nhưng đó là một câu chuyện khác).
Kỹ thuật thứ hai là sử dụng LD_PRELOAD và nạp trước một thư viện đơn giản ghi đè các hàm liên quan, buộc chúng phải đi đến các syscall thật chậm. Cách này hoạt động tốt:
Để nạp nó:
$ gcc -Wall -Wextra -fpic -shared -o vdso_override.so vdso_override.c
$ LD_PRELOAD=./vdso_override.so \
strace -e trace=gettimeofday,clock_gettime,time \
date
clock_gettime(CLOCK_REALTIME, {tv_sec=1688656245 ...}) = 0
Thu Jul 6 05:10:45 PM CEST 2023
+++ exited with 0 +++
Tuyệt vời! Chúng ta có thể thấy cuộc gọi clock_gettime trong đầu ra của strace. Chắc chắn chúng ta cũng sẽ thấy gettimeofday từ ping của mình, đúng không?
Chưa vội, nó vẫn chưa hoạt động hoàn toàn:
$ LD_PRELOAD=./vdso_override.so \
strace -c -e trace=gettimeofday,time,clock_gettime -f \
ping -n -c1 1.1 >/dev/null
... nil ...
Có nên đặt suid hay không?
Tôi quên mất rằng ping có thể cần quyền đặc biệt để đọc và ghi các gói tin thô (raw packets). Về mặt lịch sử, nó có bit suid được đặt, cấp cho chương trình danh tính người dùng nâng cao. Tuy nhiên, LD_PRELOAD không hoạt động với suid. Khi một chương trình đang được nạp, bộ liên kết động kiểm tra xem nó có bit suid không, và nếu có, nó sẽ bỏ qua các cài đặt LD_PRELOAD và LD_LIBRARY_PATH.
Tuy nhiên, ping có cần suid không? Ngày nay hoàn toàn có thể gửi và nhận các thông báo ICMP Echo mà không cần bất kỳ quyền đặc biệt nào, như thế này:
from socket import *
import struct
sd = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP)
sd.connect(('1.1', 0))
sd.send(struct.pack("!BBHHH10s", 8, 0, 0, 0, 1234, b'payload'))
data = sd.recv(1024)
print('type=%d code=%d csum=0x%x id=%d seq=%d payload=%s' % struct.unpack_from("!BBHHH10s", data))
Bây giờ bạn biết cách viết "ping" trong 8 dòng Python. API Linux này được gọi là ping socket. Nó thường hoạt động trên Linux hiện đại, tuy nhiên nó yêu cầu một sysctl đúng, thường được bật:
$ sysctl net.ipv4.ping_group_range
net.ipv4.ping_group_range = 0 2147483647
Ping socket không trưởng thành bằng socket UDP hay TCP. Trường "ICMP ID" được sử dụng để gửi ICMP Echo Response đến socket phù hợp, nhưng khi sử dụng bind(), thuộc tính này có thể được đặt bởi người dùng mà không cần bất kỳ kiểm tra nào. Một người dùng độc hại có thể cố ý gây ra xung đột "ICMP ID".
Nhưng chúng ta ở đây không để thảo luận về API mạng của Linux. Chúng ta ở đây để thảo luận về tiện ích ping và quả thực, nó đang sử dụng ping socket:
Ping socket không cần root, và ping, ít nhất là trên laptop của tôi, không phải là chương trình suid:
$ ls -lah `which ping`
-rwxr-xr-x 1 root root 75K Feb 5 2022 /usr/bin/ping
Vậy tại sao LD_PRELOAD lại không hoạt động? Hóa ra là binary ping giữ một khả năng CAP_NET_RAW. Tương tự như suid, điều này ngăn chặn cơ chế nạp trước thư viện hoạt động:
$ getcap `which ping`
/usr/bin/ping cap_net_raw=ep
Tôi nghĩ khả năng này chỉ được bật để xử lý trường hợp sysctl net.ipv4.ping_group_range bị cấu hình sai. Đối với tôi, ping hoạt động hoàn toàn tốt mà không cần khả năng này.
Không cần root là hoàn toàn ổn
Hãy xóa CAP_NET_RAW và thử thủ thuật LD_PRELOAD lại:
$ cp `which ping` .
$ LD_PRELOAD=./vdso_override.so strace -f ./ping -n -c1 1.1
...
setsockopt(3, SOL_SOCKET, SO_TIMESTAMP_OLD, [1], 4) = 0
gettimeofday({tv_sec= ... ) = 0
sendto(3, ...)
setitimer(ITIMER_REAL, {it_value={tv_sec=10}}, NULL) = 0
recvmsg(3, { ... cmsg_level=SOL_SOCKET,
cmsg_type=SO_TIMESTAMP_OLD,
cmsg_data={tv_sec=...}}, )
Cuối cùng chúng ta cũng làm được rồi! Không có -U, trong chế độ "network timestamp", ping sẽ:
- Đặt cờ
SO_TIMESTAMPtrên socket. - Gọi
gettimeofdaytrước khi gửi gói tin. - Khi tìm nạp gói tin, lấy timestamp từ CMSG.
Chèn lỗi - Lừa gạt ping
Với strace đang chạy, chúng ta cuối cùng có thể làm điều gì đó thú vị. Bạn biết đấy, strace có một tính năng chèn lỗi ít được biết đến, được gọi là "can thiệp" (tampering) trong hướng dẫn sử dụng:
Kết quả của việc chèn lỗi với strace
Với một vài tham số dòng lệnh, chúng ta có thể ghi đè kết quả của cuộc gọi gettimeofday. Tôi muốn đặt nó tiến lên để làm ping nhầm tưởng thời gian SO_TIMESTAMP là trong quá khứ:
LD_PRELOAD=./vdso_override.so \
strace -o /dev/null -e trace=gettimeofday \
-e inject=gettimeofday:poke_exit=@arg1=ff:when=1 -f \
./ping -c 1 -n 1.1.1.1
PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
./ping: Warning: time of day goes back (-59995290us), taking countermeasures
./ping: Warning: time of day goes back (-59995104us), taking countermeasures
64 bytes from 1.1.1.1: icmp_seq=1 ttl=60 time=0.000 ms
--- 1.1.1.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.000/0.000/0.000/0.000 ms
Nó hoạt động! Bây giờ chúng ta có thể tạo thông báo "taking countermeasures" một cách đáng tin cậy!
Mặc dù chúng ta có thể gian lận kết quả gettimeofday, với strace thì không thể ghi đè timestamp CMSG. Có lẽ có thể điều chỉnh timestamp CMSG với không gian thời gian (time namespaces) của Linux, nhưng tôi không nghĩ nó sẽ hoạt động. Theo như tôi hiểu, không gian thời gian không được mạng stack tính đến. Một chương trình sử dụng SO_TIMESTAMP được coi là so sánh nó với đồng hồ hệ thống, vốn có thể bị quay ngược lại.
Lừa tôi một lần, lừa tôi hai lần
Tại thời điểm này, chúng ta có thể kết thúc cuộc điều tra. Bây giờ chúng ta có thể kích hoạt thông báo "taking countermeasures" một cách đáng tin cậy bằng cách sử dụng chèn lỗi của strace.
Nhưng còn một điều nữa. Khi gửi các thông báo ICMP Echo Request, ping có nhớ timestamp gửi trong một bảng băm nào đó không? Điều đó có thể lãng phí khi xem xét một ping chạy lâu dài gửi hàng nghìn gói tin.
Ping rất thông minh, và thay vào đó đặt timestamp trong phần tải (payload) của gói tin ICMP Echo Request!
Đây là cách thuật toán đầy đủ hoạt động:
- Ping đặt tùy chọn socket
SO_TIMESTAMP_OLDđể nhận timestamps. - Nó nhìn vào đồng hồ tường với
gettimeofday. - Nó đặt timestamp hiện tại vào các byte đầu tiên của payload ICMP.
- Sau khi nhận gói tin ICMP Echo Reply, nó kiểm tra hai timestamp: timestamp gửi từ payload và timestamp nhận từ CMSG.
- Nó tính toán delta RTT.
Điều này khá gọn gàng! Với thuật toán này, ping không cần nhớ nhiều, và có thể có số lượng gói tin không giới hạn đang bay! (Để hoàn thiện, ping duy trì một bitmap kích thước cố định nhỏ để tính đến các gói DUP!).
Và nếu chúng ta đặt độ dài gói tin nhỏ hơn 16 byte thì sao? Hãy xem:
$ ping 1.1 -c2 -s0
PING 1.1 (1.0.0.1) 0(28) bytes of data.
8 bytes from 1.0.0.1: icmp_seq=1 ttl=60
8 bytes from 1.0.0.1: icmp_seq=2 ttl=60
--- 1.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1002ms
Trong trường hợp đó, ping chỉ cần bỏ qua RTT khỏi đầu ra. Thông minh!
Điều này mở ra hai chủ đề hoàn toàn mới. Mặc dù ping được viết vào thời điểm mọi người đều thân thiện, Internet ngày nay có thể có những tác nhân độc hại. Nếu chúng ta giả mạo các phản hồi để gây nhầm lẫn cho ping thì sao. Chúng ta có thể: cắt payload để ngăn ping tạo ra RTT, và giả mạo timestamp để lừa các phép đo RTT không?
Cả hai việc đều hoạt động! Trường hợp bị cắt ngắn sẽ trông như sau đối với người gửi:
Trường hợp thứ hai, timestamp bị ghi đè, còn thú vị hơn. Chúng ta có thể đẩy timestamp tiến lên khiến ping hiển thị thông báo "taking countermeasures" yêu thích của chúng ta:
Kết quả khi giả mạo timestamp
Hoặc chúng ta có thể đẩy thời gian trong gói tin lùi lại khiến ping hiển thị các giá trị RTT vô nghĩa:
Chúng ta đã chứng minh rằng "countermeasures" chỉ hoạt động khi thời gian di chuyển theo một hướng. Ở hướng khác, ping chỉ bị lừa.
Giây nhuận (Leap second)
Trong thực tế, tần suất thời gian thay đổi trên máy tính là bao nhiêu? Trình nền NTP điều chỉnh đồng hồ liên tục để tính toán bất kỳ sự trôi nào. Tuy nhiên, những thay đổi này rất nhỏ. Ngoài việc đồng bộ hóa đồng hồ ban đầu sau khi khởi động hoặc thức dậy từ chế độ ngủ, các bước nhảy đồng hồ lớn thực sự không nên xảy ra.
Luôn có ngoại lệ. Các hệ thống hoạt động trong môi trường ảo hoặc có kết nối Internet không ổn định thường trải qua việc đồng hồ bị mất đồng bộ.
Một trường hợp đáng chú ý ảnh hưởng đến tất cả các máy tính là một điều chỉnh đồng hồ được phối hợp gọi là giây nhuận. Nó khiến đồng hồ di chuyển lùi lại, điều này đặc biệt gây rắc rối. Một vấn đề trong việc xử lý giây nhuận đã gây đau đầu cho các kỹ sư của chúng tôi vào cuối năm 2016.
Giây nhuận thường gây ra các vấn đề, vì vậy consensus hiện tại là loại bỏ chúng vào năm 2035. Tuy nhiên, theo Wikipedia, giải pháp dường như là chỉ đẩy vấn đề xuống đường:
Một biện pháp có thể trong tương lai là để sự chênh lệch tăng lên một phút đầy đủ, điều này sẽ mất 50 đến 100 năm, và sau đó có phút cuối cùng của ngày kéo dài hai phút trong một "loại smearing" mà không có sự gián đoạn.
Dù sao, kể từ năm 2016 chưa có giây nhuận nào, có thể sẽ có một số trong tương lai, nhưng có khả năng sẽ không có sau năm 2035. Nhiều môi trường đã sử dụng kỹ thuật "leap second smear" để tránh vấn đề đồng hồ nhảy ngược lại.
Trong hầu hết các trường hợp, việc bỏ qua các thay đổi đồng hồ có thể hoàn toàn ổn. Khi có thể, để đếm thời lượng thời gian hãy sử dụng CLOCK_MONOTONIC, thứ này "chống đạn".
Chúng ta chưa đề cập đến các điều chỉnh đồng hồ theo giờ tiết kiệm ánh sáng ban ngày (DST) ở đây vì, từ góc độ máy tính, chúng không phải là thay đổi đồng hồ thật! Hầu hết các lập trình viên xử lý đồng hồ hệ điều hành, thường được đặt thành múi giờ UTC. Múi giờ DST chỉ được tính đến khi in ngày giờ đẹp trên màn hình. Phần mềm cơ bản hoạt động trên các giá trị nguyên.
Bài học
Có thể nói, việc đồng hồ nhảy lùi lại là một sự kiện hiếm gặp. Rất khó để kiểm tra các trường hợp như vậy, và tôi ngạc nhiên khi thấy ping đã thực hiện một nỗ lực như vậy. Để tránh vấn đề này, để đo độ trễ, ping có thể sử dụng CLOCK_MONOTONIC, các nhà phát triển của họ đã sử dụng nguồn thời gian này ở một nơi khác.
Thật không may điều này sẽ không hoạt động hoàn toàn ở đây. Ping cần so sánh timestamp gửi với timestamp nhận từ SO_TIMESTAMP CMSG, thứ sử dụng đồng hồ hệ thống không đơn điệu. API của Linux đôi khi bị giới hạn, và việc xử lý thời gian là khó. Trong thời gian tới, các điều chỉnh đồng hồ sẽ tiếp tục gây nhầm lẫn cho ping.
Dù sao, bây giờ chúng ta biết phải làm gì khi ping đang "taking countermeasures"! Hãy hạ kính tiềm vọng của bạn xuống và kiểm tra trạng thái của trình nền NTP!
Bài viết liên quan

AI & ML
Nguy cơ bảo mật từ "Vibe-Coding": Hàng nghìn ứng dụng AI để lộ dữ liệu nhạy cảm trên mạng
07 tháng 5, 2026
AI & ML
MFA chỉ là bước khởi đầu: Tại sao xác thực thành công vẫn không ngăn chặn được tin tặc?
21 tháng 5, 2026

Phần mềm
Runtime ra mắt hạ tầng sandbox cho coding agents, giúp toàn bộ đội ngũ phát triển phần mềm an toàn
21 tháng 5, 2026
