Endpoint Security là API của macOS mà các sản phẩm EDR dùng để thấy mọi lần chạy chương trình, đăng nhập hay cấp quyền. Muốn dùng trực tiếp thì cần một entitlement do Apple xét duyệt từng trường hợp. Bài này kể cách MacSentry, công cụ giám sát máy Mac mã nguồn mở của mình, lấy cùng những sự kiện đó qua /usr/bin/eslogger, và những gì cần làm để nó vừa nhanh vừa không báo nhầm: đọc JSON Lines mà không phải parse phần lớn các dòng, áp quy tắc vào đúng chương trình, và không tự báo động về chính mình.
eslogger cho bạn sự kiện Endpoint Security mà không cần entitlement
Một client Endpoint Security viết trực tiếp cần entitlement com.apple.developer.endpoint-security.client, và tài liệu của Apple nói rõ là phải xin. Với một công cụ cá nhân, người dùng tự build từ mã nguồn, chuyện này không thực tế.
macOS có sẵn eslogger, một client Endpoint Security dạng dòng lệnh, in sự kiện ra dưới dạng JSON Lines. Helper macsentry-es của MacSentry là một LaunchDaemon chạy bằng root, khởi động eslogger với danh sách tên sự kiện rồi đọc stdout của nó. Man page eslogger(1) đặt ra vài điều kiện, và mỗi điều đều ảnh hưởng tới thiết kế:
- Phải chạy bằng root, và tiến trình chịu trách nhiệm phải có Full Disk Access. Vì helper chạy
esloggernhư tiến trình con, macOS xét quyền trên binary của helper. Nên Full Disk Access được cấp cho/usr/local/libexec/macsentry-es, không phải choeslogger. - Chỉ hỗ trợ sự kiện notify, không bao giờ có sự kiện auth. Bạn thấy được chuyện gì đã xảy ra nhưng không chặn được. Điều này khớp với bản chất của MacSentry: một chiếc chuông báo động, không phải tấm khiên.
- "eslogger is NOT API in any sense." Định dạng đầu ra có thể đổi giữa các bản macOS mà không báo trước. Vì vậy helper lưu lại mẫu JSON thô đầu tiên của từng loại sự kiện, và một script nhỏ in ra cấu trúc của chúng. Khi một trường đổi chỗ, mình thấy ngay nó đi đâu thay vì đoán. Việc đọc trường cũng được viết "dễ tính": ví dụ lý do thất bại của sự kiện
sudođược tìm ở nhiều vị trí. - Nó bỏ qua sự kiện của chính nhóm tiến trình (process group) của mình, để tránh vòng lặp phản hồi. Điều đó che được helper, nhưng không che được daemon root chạy riêng. Phần dưới sẽ quay lại chuyện này.
Helper đăng ký khoảng hai mươi loại sự kiện: authentication, tcc_modify, exec, sudo, su, openssh_login, screensharing_attach, các sự kiện của XProtect và Gatekeeper, Background Task Management (btm_launch_item_add), configuration profile và thay đổi Open Directory (user mới, sửa nhóm admin). eslogger --list-events in ra những gì bản macOS của bạn hỗ trợ.
Một lợi ích phụ: sudo, su và openssh_login thay được trình đọc log stream, thứ tốn khoảng 5% một nhân CPU. Giờ daemon chỉ chạy log stream để dự phòng khi helper không chạy. Phần còn lại của daemon chuyển từ polling sang sự kiện thế nào, mình kể trong bài viết lại daemon của MacSentry từ Bash sang Swift.
Đọc JSON Lines bằng getline và bỏ qua parser cho phần lớn các dòng
Gần như toàn bộ lưu lượng là exec. Mỗi lần chạy chương trình thành một dòng chứa đủ danh sách tham số và biến môi trường, thường khoảng 4 KB, và một máy dev chạy rất nhiều chương trình. Chỉ một phần cực nhỏ trong số đó có khả năng khớp quy tắc, nên mục tiêu là loại phần còn lại trước khi cấp phát bất cứ thứ gì.
Hai thay đổi làm nên phần lớn khác biệt:
- Đọc từng dòng bằng
getline(3). Hàm này đệm ở user space và dùng lại một buffer cho mọi dòng. Những cách "tự nhiên" trong Swift đều cấp phát hoặc sao chép mỗi dòng:FileHandle.availableDatarồi cắt phần đã đọc khỏi một bufferDatathì sao chép phần còn lại mỗi lần, cònreadLine()tạo mộtStringcho mỗi dòng. - Lọc dòng
execngay trên byte thô. Mỗi quy tắc khai báo các needle, tức chuỗi mà dòng thô bắt buộc phải chứa thì quy tắc mới có khả năng khớp. Mọi needle được gom vào một automaton Aho–Corasick, dựng thành DFA không phân biệt hoa thường, nên một lượt quét qua dòng kiểm tra được tất cả cùng lúc, thay vì gọimemmemcho từng needle. Dòng không chứa needle nào bị bỏ luôn, không bao giờ tớiJSONSerialization.
func execIrrelevant(_ hay: UnsafeRawBufferPointer) -> Bool {
let isExec = execMarker.withUnsafeBytes { memmem(hay.baseAddress, hay.count, $0.baseAddress, $0.count) != nil }
return isExec && !needleMatcher.matches(hay)
}
Một chi tiết dễ sót: JSON có thể escape / thành \/, nên needle như /dev/tcp/ được thêm cả hai dạng.
Với hai thay đổi này, phát lại 100.000 sự kiện đã ghi qua helper mất 0,77 giây thay vì 8,7 giây. Chế độ phát lại (macsentry-es --replay < events.jsonl) không cần root và không gửi gì, nên đo lại rất dễ.
Dù vậy, theo dõi exec vẫn là phần tốn nhất của MacSentry, vì bản thân eslogger phải tuần tự hoá mọi lần chạy chương trình. Trên một máy dev bận rộn, helper và eslogger cộng lại dùng khoảng 2% một nhân, nên trình cài đặt hỏi có bật không, và ES_EXEC=0 để tắt.
Áp quy tắc vào chương trình đang chạy, không vào chữ
Một biểu thức chính quy quét cả dòng lệnh không phân biệt được lệnh với dữ liệu nhắc tới lệnh đó. Chính bản shell đời đầu của MacSentry là ví dụ: nó gửi tin Telegram bằng curl, nên một cảnh báo mô tả việc đọc keychain sẽ đưa chuỗi security find-generic-password vào tham số của curl, và một quy tắc quét dòng lệnh sẽ thấy rồi báo động thêm lần nữa.
Cách sửa là gắn cho mỗi quy tắc tập chương trình mà nó áp dụng (trích từ es/Rules.swift):
rule("🔑 Lệnh đọc mật khẩu trong Keychain", .alert, ["security"],
#"(^|/)security\s+(dump-keychain|(find-generic-password|find-internet-password)\b.*\s-[wg]\b|export\b)"#)
Quy tắc keychain chỉ được xét khi file chạy là security. Mẫu reverse shell chỉ áp cho shell và trình thông dịch (bash, nc, socat, python, osascript...). Một danh sách riêng các chương trình chở dữ liệu (curl, wget, echo, grep, git...) không bao giờ bị xét, vì tham số của chúng là dữ liệu chứ không phải lệnh. Tập chương trình này cũng là needle mặc định cho bộ lọc ở trên, nên hai lớp luôn thống nhất quy tắc nói về cái gì.
Công cụ hợp lệ vẫn có lúc chạm quy tắc. Ví dụ Claude Code đọc token đăng nhập của chính nó trong Keychain bằng security find-generic-password. Thay vì nới quy tắc, /etc/macsentry.allow chứa các regex so với chuỗi "<lệnh> || <chuỗi tiến trình cha>", nên một ngoại lệ có thể nêu cả lệnh lẫn chương trình đã chạy nó. Các lần khớp được cho phép chỉ ghi log tối đa mỗi giờ một lần, vì chúng thường lặp liên tục.
Đừng báo động về lệnh của chính mình
Daemon của MacSentry có chạy vài lệnh mà quy tắc sẽ bắt, và vì nó là một LaunchDaemon riêng, cơ chế bỏ qua theo process group của eslogger không che được chúng. Hai kỹ thuật xử lý chuyện này:
- Xem dòng lệnh đầy đủ của các tiến trình cha. Với mỗi
execđáng ngờ, helper đi ngược chuỗi tiến trình cha qualibprocvàsysctl(KERN_PROCARGS2), rồi bỏ qua sự kiện nếu có tổ tiên nào là binary của MacSentry. Chỉ tên tiến trình là không đủ, phải nhìn dòng lệnh mới thấy/usr/local/libexec/macsentryd. - Đánh dấu trước sự kiện mình sắp gây ra, khi tiến trình cha đã biến mất. Để đọc cài đặt khoá màn hình, daemon chạy
sudo -u <user> sysadminctl, và khi sự kiệnsudotới thì các tiến trình trung gian đã thoát. Nên daemon chạm vào một file đánh dấu ngay trước khi gọi, và helper bỏ quasudodo root khởi tạo trong vòng 15 giây sau đó.
Đi ngược từ PID cha được ghi trong sự kiện, chứ không phải từ chính tiến trình, cũng quan trọng: tiến trình sống ngắn thường đã thoát trước khi sự kiện được xử lý.
Những chi tiết vận hành nhỏ giúp tránh lỗi im lặng
- Ghi stderr của
esloggerra file, đừng dùng pipe. Nếu không ai đọc pipe trong lúc tiến trình con chạy, pipe sẽ đầy vàesloggertreo mãi mãi, không có lỗi nào ở đâu cả. File còn giữ lại lỗi cuối cùng để chẩn đoán, ví dụ "not permitted" khi thiếu Full Disk Access. - Báo còn sống ngay cả khi máy rảnh. Helper chạm vào một file heartbeat khi có sự kiện và theo một timer 60 giây khi
esloggercòn chạy, và daemon cảnh báo nếu file đó quá cũ. Không có timer, một chiếc máy yên tĩnh sẽ trông như helper đã chết. - Chuyển kết quả cho daemon qua một spool, đổi tên nguyên tử. Helper ghi
name.tmprồi đổi tên thànhname.msg, nên daemon không bao giờ đọc phải tin nhắn viết dở. Helper không bao giờ giữ token Telegram, và nó che token bot cùng các bí mật dạngKEY=valuetrước khi ghi bất cứ thứ gì.
Tóm tắt
| Vấn đề | MacSentry làm gì |
|---|---|
| Endpoint Security cần entitlement do Apple duyệt | Chạy /usr/bin/eslogger làm tiến trình con của một helper root có Full Disk Access |
| Định dạng đầu ra không phải API ổn định | Lưu một mẫu thô cho mỗi loại sự kiện, đọc trường theo cách dễ tính |
Dòng exec lớn và nhiều |
getline cộng bộ lọc Aho–Corasick một lượt trên byte thô (8,7 giây → 0,77 giây cho 100 nghìn sự kiện) |
| Quy tắc khớp nhầm chữ nằm trong dữ liệu | Quy tắc gắn với file chạy, chương trình chở dữ liệu không bao giờ bị xét |
| Báo động về lệnh của chính mình | Xem dòng lệnh của tiến trình cha, file đánh dấu cho sudo dự kiến |
| Treo im lặng, chết im lặng | stderr ra file, file còn sống theo timer, spool nguyên tử |
eslogger không thay được một system extension có chữ ký: nó không chặn được gì và định dạng có thể đổi bất cứ lúc nào. Nhưng với một công cụ cá nhân chỉ cần thấy chuyện gì xảy ra trên một chiếc Mac, nó đưa bạn tới rất gần.
Nếu bạn từng xây công cụ giám sát trên eslogger hay một client Endpoint Security riêng, mình rất muốn được trao đổi. Email của mình ở trang chủ.