Lời nói đầu

Chuyện là hôm nay lúc vào trang quản trị WordPress để kiểm tra hệ thống như thường lệ, mình phát hiện một cảnh tượng khá “hết hồn”: danh sách tài khoản trên site bỗng dưng phình to lên hơn 1.300 users, mà oái oăm thay gần như toàn bộ đều chễm chệ ở quyền cao nhất — Administrator.

Nếu anh em quản trị web lâu năm thì chắc không lạ gì cảnh web bị chèn shell hay tạo lén 1–2 tài khoản admin ẩn. Tuy nhiên, chiến dịch tấn công lần này lại có rất nhiều điểm thú vị: kẻ tấn công không chỉ cài cắm mã độc giả danh plugin bảo mật vào thư mục mu-plugins, mà còn xây dựng một cơ chế Self-Healing (Tự phục hồi) cực kỳ ranh ma kết hợp giữa Database, các file ẩn tên ký tự nhị phân dị dạng và hàng loạt gói .zip rải khắp các thư mục. Xóa xong F5 lại là file mã độc lập tức “sống dậy”.

Sau một hồi truy vết qua cả Database lẫn SSH Terminal để bóc gỡ từng lớp ngụy trang của nó, mình note lại toàn bộ quá trình điều tra, phân tích cơ chế hoạt động cũng như bộ câu lệnh (SQL + Linux CLI + WP-CLI) xử lý tận gốc trong bài Case Study này để anh em tham khảo nếu chẳng may gặp phải.

Tình trạng

Khi truy cập vào trang quản trị /wp-admin/users.php, hệ thống xuất hiện các dấu hiệu bất thường rất rõ rệt:

  • Số lượng tài khoản quản trị (Administrator) tăng vọt lên tới 1.334 tài khoản (tổng cộng hơn 1.348 tài khoản rác khi truy vấn trực tiếp trong Database).
  • Hàng loạt user lạ được tạo tự động với các tiền tố cố định: adm_, admin_, administrator_, backup_ ghép với một chuỗi mã hex ngẫu nhiên (ví dụ: adm_003706a4, admin_0063ec90, backup_043095b9…) và sử dụng email giả mạo theo đúng tên miền của website.
  • Ngay trên đầu giao diện quản trị xuất hiện dòng cảnh báo lỗi PHP:Warning: Undefined array key "wpdb" in /home/<hosting_user>/public_html/wp-content/mu-plugins/prime-sentinel-tag.php on line 4431
  • Hàng trăm thư mục profile rỗng được tạo liên tục trong wp-content/uploads/ultimatemember/.

Nhận định & Phân tích mã độc

Chỉ từ dòng cảnh báo lỗi PHP lộ ra ở trang Users, mình có ngay manh mối quan trọng nhất để nhận diện chiến dịch tấn công này: prime-sentinel-tag.php nằm trong thư mục wp-content/mu-plugins/.

1. Tại sao lại là mu-plugins (Must-Use Plugins)?

Trong kiến trúc của WordPress, các plugin thông thường nằm ở wp-content/plugins/ phải được kích hoạt mới chạy và hiển thị rõ ràng trong menu quản trị. Ngược lại, mu-plugins (Must-Use Plugins) là thư mục đặc biệt:

  • Bất kỳ file .php nào nằm ở cấp gốc của wp-content/mu-plugins/ đều được WordPress tự động nạp và thực thi trên mọi HTTP request (cả frontend lẫn backend) trước cả các plugin bình thường.
  • Chúng không xuất hiện trong danh sách plugin mặc định và không có nút “Deactivate” trên giao diện.
  • Kẻ tấn công đặt tên file là prime-sentinel-tag.php (với phần header giả mạo Plugin URI: [https://allen.dev/plugins/prime-sentinel-tag/](https://allen.dev/plugins/prime-sentinel-tag/)) nhằm ngụy trang giống như một module tường lửa hoặc công cụ tracking hợp lệ.

2. Giải mã lý do mã độc “đẻ” hơn 1.300 tài khoản Admin

Thông thường, hacker chỉ cần tạo 1 đến 2 tài khoản administrator ẩn để làm backdoor duy trì quyền truy cập (Persistence). Vậy tại sao ở case này nó lại nhân bản lên tới hơn 1.348 tài khoản?

Câu trả lời nằm ở chính dòng lỗi Warning: Undefined array key "wpdb" ... on line 4431:

  • File prime-sentinel-tag.php dài hơn 4.400 dòng code chứa hàm kiểm tra xem trên website đã tồn tại tài khoản backdoor nào thuộc các tiền tố adm_, admin_, backup_ hay chưa.
  • Tuy nhiên, đoạn code kiểm tra Database của hacker ở dòng 4431 lại viết lỗi khi truy xuất biến toàn cục $GLOBALS['wpdb'] ở thời điểm quá sớm hoặc sai phạm vi (scope).
  • Hậu quả là hàm kiểm tra sự tồn tại của user backdoor luôn trả về false. Thế là cứ mỗi khi có người dùng hoặc bot truy cập vào website (mỗi HTTP request), mã độc lại tưởng rằng chưa có tài khoản hậu phương và lập tức gọi hàm tạo mới thêm 1 tài khoản administrator! Vô tình, chính “bug” này của kẻ tấn công lại làm lộ diện hành vi ẩn nấp của chúng.

Truy vết

Bước 1: Dọn phần ngọn và cú lừa “Tự phục hồi” (Self-Healing)

Ban đầu, thao tác phản xạ tự nhiên nhất là:

  1. Vào File Manager xóa thẳng tay file /wp-content/mu-plugins/prime-sentinel-tag.php.
  2. Mở phpMyAdmin chạy truy vấn SQL xóa toàn bộ các tài khoản mang tiền tố rác khỏi bảng wp_users và wp_usermeta. Kết quả quét sạch 1.348 dòng trong wp_users và 24.383 dòng trong wp_usermeta chỉ trong chưa đầy 2 giây.

Những tưởng thế là xong, nhưng ngay khi vừa F5 lại trình duyệt và quay sang kiểm tra thư mục mu-plugins, file prime-sentinel-tag.php đã tự động sinh lại y nguyên! Cảm giác game bắt đầu khó.

Khi một file PHP tự động tái sinh sau khi xóa, chỉ có thể rơi vào các kịch bản:

  1. Có một đoạn mã nạp (Loader/Dropper) được chèn vào các file chạy đầu tiên của hệ thống (index.php, wp-config.php, file bootstrap của tường lửa, hoặc functions.php của theme).
  2. Toàn bộ nội dung mã độc được lưu trong Database (wp_options) hoặc một file nén ẩn trên đĩa cứng để giải nén lại khi mất file.
  3. Có tiến trình ngầm (daemon) hoặc Cronjob đang chạy lặp.

Bước 2: Kiểm tra Core files và Database (wp_options)

Mình tiến hành kiểm tra đầu và cuối file index.php, wp-config.php và aios-bootstrap.php. Phần code khởi tạo không bị chèn lệnh eval(base64_decode(...)) lạ, nhưng phát hiện đường dẫn ABSPATH trong aios-bootstrap.php đang trỏ sai thư mục cũ khiến lớp tường lửa AIOS hoạt động không chuẩn xác.

Tiếp tục nhảy vào Database tìm các option chứa từ khóa liên quan đến sentinel và xóa bỏ:

DELETE FROM wp_options 
WHERE option_name LIKE '%sentinel%' 
   OR option_value LIKE '%prime-sentinel-tag.php%';

Truy vấn lập tức xóa được 4 bản ghi ẩn trong wp_options.

Bước 3: Lần theo dấu vết trên SSH Terminal và manh mối từ Group nobody

Để tìm xem “ổ mẹ” (dropper) đang nằm ở đâu trên đĩa cứng, mình mở Terminal SSH vào server và chạy grep toàn bộ mã nguồn:

grep -rnI "prime-sentinel" .

Kết quả trả về một đường dẫn cực kỳ quái dị ngay trong thư mục wp-admin/:

./wp-admin/<:5: * Plugin URI:        https://allen.dev/plugins/prime-sentinel-tag/
./wp-admin/<:10: * Text Domain:       prime-sentinel-tag

Khi chạy ls -la trong public_html/ và wp-admin/, toàn bộ chân tướng ổ chứa của mã độc lộ diện:

  • Kẻ tấn công tạo ra các file có tên là các ký tự điều khiển nhị phân hỏng font ('<'$'\004\307\a\256' nặng 6.6 MB và ''$'\034\020''z' 11 Bytes) để làm rối mắt quản trị viên khi nhìn trên File Manager web và gây khó khăn khi gõ lệnh rm thông thường.
  • Kèm theo đó là một loạt file ẩn điều khiển trạng thái (watchdog/heartbeat/state files):
    • Trong public_html/ và wp-admin/: .gk_, .gp_, .grd_ced81ea4f2de, .gs_, .gt_, __HB__, cùng một cây thư mục rác wp-admin/C_/xampp/htdocs/wordpress/....
    • Trong wp-content/mu-plugins/: .bt_prime-sentinel-tag, .pv_prime-sentinel-tag, .rd_prime-sentinel-tag, .sd_prime-sentinel-tag, .swm_prime-sentinel-tag.
    • Trong wp-content/: 2 file mồi 078604ef.php và c251adcb.php.

Điểm mấu chốt khi điều tra trên Linux:

Quan sát cột phân quyền của ls -la, toàn bộ file chuẩn của mã nguồn đều có owner:group là <hosting_user>:<hosting_user>. Riêng tất cả các file do mã độc/webshell sinh ra thông qua tiến trình web server PHP đều mang group là nobody!

Bước 4: Bắt sống các “ổ trứng” dự phòng (ace3e62c.zip)

Dựa vào đặc điểm group nobody và tìm kiếm toàn bộ các file nén trên hệ thống bằng lệnh find:

find . -type f \( -name "*.zip" -o -name "*.tar*" -o -name "*.gz" \) -ls

Mình phát hiện kẻ tấn công còn rải 4 gói nén mang tên ace3e62c.zip (dung lượng ~140KB, thuộc group nobody) nằm rải rác ở nhiều nơi:

  • ./wp-content/themes/flatsome/ace3e62c.zip
  • ./wp-content/ace3e62c.zip
  • ./wp-content/uploads/2026/09/ace3e62c.zip
  • ./wp-content/uploads/2026/10/ace3e62c.zip

Khi kiểm tra nội dung bên trong gói zip bằng unzip -l:

Archive:  wp-content/themes/flatsome/ace3e62c.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
   439786  10-16-2026 23:44   prime-sentinel-tag/prime-sentinel-tag.php

Đúng như dự đoán, đây chính là các “ổ trứng” dự phòng dùng để bung lại file prime-sentinel-tag.php mỗi khi phát hiện file thực thi bị xóa!

Xử lý

Để tiêu diệt triệt để chiến dịch mã độc này và gia cố lại hệ thống (Hardening), dưới đây là quy trình xử lý toàn diện từ Database đến Server CLI mà mình đã thực hiện:

1. Xóa toàn bộ tài khoản Admin rác và dữ liệu bẩn trong Database

Chạy truy vấn SQL trong phpMyAdmin (hoặc qua wp db query) để xóa toàn bộ user mang tiền tố của mã độc (nhớ loại trừ các tài khoản quản trị thật của bạn):

-- 1. Xóa toàn bộ usermeta của các tài khoản rác
DELETE m FROM wp_usermeta m
INNER JOIN wp_users u ON m.user_id = u.ID
WHERE (
    u.user_login LIKE 'adm\_%' 
    OR u.user_login LIKE 'admin\_%' 
    OR u.user_login LIKE 'administrator\_%' 
    OR u.user_login LIKE 'backup\_%'
) 
AND u.user_login NOT IN ('admin');

-- 2. Xóa toàn bộ tài khoản rác khỏi bảng wp_users
DELETE FROM wp_users 
WHERE (
    user_login LIKE 'adm\_%' 
    OR user_login LIKE 'admin\_%' 
    OR user_login LIKE 'administrator\_%' 
    OR user_login LIKE 'backup\_%'
) 
AND user_login NOT IN ('admin');

-- 3. Dọn dẹp các bản ghi persistence trong wp_options và tối ưu bảng
DELETE FROM wp_options 
WHERE option_name LIKE '%sentinel%' 
   OR option_value LIKE '%prime-sentinel-tag.php%';

DELETE FROM wp_usermeta WHERE user_id NOT IN (SELECT ID FROM wp_users);
OPTIMIZE TABLE wp_users, wp_usermeta, wp_options;

(Lưu ý nhỏ về SQL: Trong toán tử LIKE, ký tự gạch dưới _ mặc định là wildcard đại diện cho 1 ký tự bất kỳ. Do đó nếu không thêm dấu \ thành admin\_% hoặc không đưa 'admin' vào danh sách NOT IN, câu lệnh LIKE 'admin_%' có thể xóa nhầm các tài khoản bắt đầu bằng chữ admin có độ dài từ 6 ký tự trở lên).

2. Tiêu diệt ổ mã độc, file ẩn và gói nén .zip trên Terminal

Chạy cụm lệnh sau tại thư mục gốc public_html:

# 1. Xóa file mã độc và các file ẩn state trong mu-plugins
chmod 0755 wp-content/mu-plugins
rm -f wp-content/mu-plugins/prime-sentinel-tag.php
rm -f wp-content/mu-plugins/.*prime-sentinel-tag*

# 2. Xóa bộ file ẩn watchdog và các file rác trong wp-admin, wp-content và thư mục gốc
rm -f wp-admin/.gk_ wp-admin/.gp_ wp-admin/.grd_* wp-admin/.gs_ wp-admin/.gt_ wp-admin/__HB__
rm -f .gk_ .gp_ .grd_* .gs_ .gt_ __HB__
rm -rf wp-admin/C_
rm -f wp-content/078604ef.php wp-content/c251adcb.php

# 3. Quét và xóa toàn bộ các bản sao của ổ trứng ace3e62c.zip
find . -type f -name "ace3e62c.zip" -delete

3. Xác minh Checksum và làm sạch Core & Plugins bằng WP-CLI

Thay vì ngồi dò từng file hệ thống bằng mắt thường, WP-CLI là vũ khí cực kỳ lợi hại giúp đối chiếu mã băm (MD5 checksum) của toàn bộ các file hiện tại với máy chủ gốc WordPress.org:

# Kiểm tra tính toàn vẹn của bộ Core WordPress
wp core verify-checksums

# Tải đè lại bộ Core sạch mà không ảnh hưởng tới thư mục wp-content
wp core download --force --skip-content

# Kiểm tra tính toàn vẹn của toàn bộ Plugins đang cài đặt
wp plugin verify-checksums --all

Đồng thời, kiểm tra lại file functions.php của theme đang kích hoạt (trong trường hợp của mình, file functions.php của theme Flatsome bị xóa mất trong lúc mã độc hoạt động nên cần tạo lại file chuẩn để tránh lỗi trắng trang/vỡ giao diện).

4. Dọn dẹp hàng trăm thư mục rác của Ultimate Member

Do hơn 1.300 tài khoản rác khi được tạo ra đã sinh kèm hàng trăm thư mục con theo user_id trong wp-content/uploads/ultimatemember/, mình dùng một đoạn script Bash ngắn kết hợp WP-CLI để chỉ giữ lại thư mục của các ID hợp lệ còn trong Database và xóa sạch các thư mục mồ côi:

cd wp-content/uploads/ultimatemember/
valid_ids=$(wp user list --field=ID --path=/home/<hosting_user>/public_html)
for dir in [0-9]*; do
    if ! echo "$valid_ids" | grep -qx "$dir"; then
        rm -rf "$dir"
    fi
done

5. Xử lý sự cố khóa IP, khôi phục tài khoản Admin gốc (ID = 1) và xoay vòng Salts

Trong quá trình xử lý, có hai tình huống thực tế rất hay gặp:

  1. Bị tường lửa AIOS khóa IP do đăng nhập sai (Access from your IP address has been blocked): Mở khóa ngay lập tức bằng WP-CLI:
wp db query "TRUNCATE TABLE wp_aiowps_login_lockdown;"
wp db query "DELETE FROM wp_aiowps_permanent_block;"
  1. Không thể tạo lại tài khoản admin do bị plugin bảo mật chặn (Sorry, that username is not allowed) và bị lệch ID bài viết: Các plugin bảo mật thường hook vào illegal_user_logins để cấm tạo username tên là admin. Để vượt qua khi chạy CLI, chỉ cần thêm cờ --skip-plugins --skip-themes, sau đó gán lại ID = 1 để khớp toàn bộ bài viết cũ (post_author = 1):
# Tạo lại tài khoản admin bỏ qua bộ lọc của plugin
wp user create admin admin@yourdomain.com --role=administrator --user_pass='<Strong_Password>' --display_name='Admin' --skip-plugins --skip-themes

# Gán lại ID về 1 (thay 5587 bằng ID vừa được tạo)
wp db query "DELETE FROM wp_usermeta WHERE user_id = 1;"
wp db query "UPDATE wp_users SET ID = 1 WHERE ID = 5587 AND user_login = 'admin';"
wp db query "UPDATE wp_usermeta SET user_id = 1 WHERE user_id = 5587;"
wp cache flush --skip-plugins --skip-themes

# Làm mới toàn bộ Authentication Salts để thu hồi mọi phiên cookie cũ
wp config shuffle-salts

6. Gia cố bảo mật (Hardening): Tạo MU-Plugin chặn Blacklist & Khóa quyền ghi 0555

Để đảm bảo không bất kỳ plugin hay lỗ hổng nào có thể tái tạo các user mang tiền tố nhạy cảm hoặc lén thả file vào mu-plugins nữa, mình triển khai chốt chặn 2 lớp:

Lớp 1: Tạo file wp-content/mu-plugins/crf-security-guard.php để chặn đứng các tiền tố nhạy cảm và tự động thu hồi quyền nếu có tài khoản lạ ngoài whitelist được nâng lên administrator:

<?php
/**
 * Plugin Name: CRF Security Guard
 * Description: Block suspicious username prefixes and unauthorized administrator creation.
 */

// 1. Chặn đăng ký/tạo username chứa các tiền tố của mã độc
add_filter('validate_username', function ($valid, $username) {
    $blacklisted = array('admin_', 'adm_', 'administrator_', 'backup_');
    foreach ($blacklisted as $prefix) {
        if (stripos($username, $prefix) === 0) {
            return false;
        }
    }
    return $valid;
}, 10, 2);

// 2. Chặn gán quyền administrator trái phép ngoài danh sách cho phép
add_action('user_register', function ($user_id) {
    $user = get_userdata($user_id);
    $allowed_admins = array('admin');
    if ($user && in_array('administrator', (array) $user->roles, true)) {
        if (!in_array($user->user_login, $allowed_admins, true)) {
            wp_delete_user($user_id);
        }
    }
}, 1);

Lớp 2: Hạ quyền thư mục mu-plugins xuống 0555 (chỉ đọc và thực thi, tước bỏ hoàn toàn quyền ghi Write):

chmod 0644 wp-content/mu-plugins/crf-security-guard.php
chmod 0555 wp-content/mu-plugins

Khi thư mục mu-plugins ở chế độ 0555 (hoặc đặt cờ bất biến chattr +i nếu bạn có quyền root trên VPS), mọi nỗ lực gọi hàm file_put_contents hay giải nén file của mã độc vào mu-plugins đều sẽ bị hệ điều hành từ chối ngay lập tức (Permission denied).

Lời kết

Các chiến dịch mã độc nhắm vào WordPress ngày càng tinh vi hơn: thay vì chỉ chèn một đoạn code đơn giản vào header.php hay footer.php như trước kia, chúng đã biết tận dụng kiến trúc mu-plugins, tạo các file tên nhị phân dị dạng và rải các gói .zip dự phòng khắp nơi để tự phục hồi sau khi bị xóa.

Qua Case Study lần này, có 3 kinh nghiệm xương máu khi ứng cứu sự cố (Incident Response) trên WordPress:

  1. Không bao giờ chỉ xóa phần ngọn trên giao diện web: Khi thấy file trong mu-plugins hoặc user admin lạ tự sinh lại, hãy lập tức nhảy vào SSH Terminal, kiểm tra owner:group của file (đặc biệt là group nobody hoặc www-data) và quét tìm các gói nén .zip ẩn.
  2. Tận dụng tối đa sức mạnh của WP-CLI: Chỉ với 2 lệnh wp core verify-checksums và wp plugin verify-checksums --all, bạn có thể tiết kiệm hàng giờ đồng hồ ngồi rà soát từng thư mục Core và Plugin.
  3. Khóa chặt mu-plugins (chmod 0555): Đây là “tử huyệt” thường bị bỏ quên nhất trên WordPress. Nếu bạn không có nhu cầu thêm mới Must-Use Plugin mỗi ngày, hãy khóa quyền ghi của thư mục này lại ngay hôm nay!

Leave a Reply

This site uses cookies to offer you a better browsing experience. By browsing this website, you agree to our use of cookies.