ไล่ webshell ทั้งเครื่อง: grep รอบแรกพลาด ต้องสร้างสแกนเนอร์ใหม่
การไล่ webshell ด้วยลายเซ็นทั่วไปจับได้แต่ของที่หน้าตาเหมือนมัลแวร์ ส่วนตัวที่เขียนเป็นโค้ดอ่านออกธรรมดาจะรอดไปได้ ผมเลยต้องย้อนกลับไปสร้างตัวสแกนจากสิ่งที่เจอจริง แล้วค่อยกักของแบบที่ย้อนกลับได้ทุกขั้น
สรุปใน 1 นาที
- อาการ/ปัญหา
- ไล่ webshell ด้วย grep ตามลายเซ็นรอบแรกพลาดไฟล์ที่เขียนเป็นโค้ดอ่านออกธรรมดา
- ใครควรอ่าน
- ผู้ดูแล shared hosting ที่ต้องไล่หา webshell ทั้งเครื่อง
- ทางแก้แบบสั้น
- สร้างสแกนเนอร์ที่ดูหัวไฟล์ด้วยกฎจากของจริงที่เจอ แล้วจัดการเป็นขั้น บันทึก ล็อก ตรวจ ย้ายเข้า quarantine พร้อม manifest ให้ย้อนกลับได้
พอดีหลังข่าวช่องโหว่ PHP รอบที่แล้ว (php2shell) กับเหตุที่ CMS เก่าตัวหนึ่งบนเครื่องโดนเจาะ เจ้าของสั่งให้ผมไล่หาร่องรอยทั้งเครื่องแบบอ่านอย่างเดียว ครอบคลุมทุกโดเมนบนเซิร์ฟเวอร์ shared hosting ที่มีบัญชีหลายสิบบัญชี ผมนึกว่า grep ตามลายเซ็นที่ใครก็รู้จักรอบเดียวก็จบ แล้วก็เจอจริงตั้งสิบกว่าตัว แต่ไม่กี่วันต่อมาตอนไล่แก้ 500 ของ subdomain หนึ่ง ผมเจอตัวจัดการไฟล์กับตัวอัปโหลดที่รอบแรกพลาดไปทั้งชุด เรื่องนี้เลยกลายเป็นบันทึกว่าทำไมต้องสร้างสแกนเนอร์ใหม่ และไล่จัดการยังไงให้ย้อนกลับได้
ชื่อบัญชี ชื่อโดเมน ชื่อไฟล์ และเส้นทางที่ชี้ตัวลูกค้า ผมไม่ใส่ไว้ทั้งหมด กฎตรวจจับที่ยกมาก็เป็นแบบตำราทั่วไป ไม่ใช่ลายเซ็นของไฟล์จริงบนเครื่องผม และไม่มีโค้ดที่เอาไปใช้โจมตีได้
ไฟล์ที่ตรวจเจอให้เปิดอ่านด้วยบัญชี root เท่านั้น ห้าม copy เข้า web root หรือเข้าเครื่องที่เปิดเว็บ และห้ามรันเพื่อลองดู ถ้าไม่แน่ใจว่าไฟล์ไหนเป็นของจริงให้ล็อกสิทธิ์ไว้ก่อน อย่าเพิ่งลบ เพราะหลักฐานกับเจ้าของเว็บยังต้องใช้
รอบแรก grep ตามลายเซ็น เจอสิบกว่าตัว แล้วผมคิดว่าจบ
ก่อนอื่นต้องบอกว่าระดับระบบสะอาด ผมไล่ดูแล้วไม่มีผู้ใช้ใหม่ ไม่มี SSH key แปลกปลอม ไม่มี cron ใหม่ ไม่มี systemd unit แปลก ไม่มีรายการ preload และไม่มีไบนารี ELF ที่ไม่ควรมี ส่วนที่เหลือเลยอยู่ที่ไฟล์เว็บในบัญชีผู้ใช้
รอบแรกผมใช้ pattern ที่คนทั่วไปใช้กัน คือหา eval(base64_decode( ที่รับค่าจาก request, system/exec/passthru ที่ต่อกับ $_GET หรือ $_POST กับชื่อเชลล์สายที่ดังอยู่แล้ว ผลคือเจอสิบกว่าตัวในหลายบัญชี หลายตัวเป็นโค้ดอำพรางหน้าตาชัดเจน บางตัวเป็นตัวโหลดผ่าน CGI ทั้งหมดเป็นของเก่า เพราะเข้ามากับไฟล์สำรองที่กู้จากเครื่องเดิมตอนย้ายบัญชีมาเครื่องนี้ ไม่ใช่ของที่เพิ่งถูกวางหลังย้าย ผมล็อกด้วย chmod 000 ที่เดิมทุกตัว
บทเรียนข้อแรกเลยมาจากตรงนี้ ย้ายบัญชีมาจากเครื่องเก่าแปลว่านำการติดเชื้อเก่ามาด้วย ต้องไล่สแกนทันทีหลังย้าย ไม่ใช่รอให้เกิดเรื่อง
ของที่รอบแรกพลาด
ตอนไล่แก้ 500 ของ subdomain หนึ่ง ผมเปิดเจอตัวจัดการไฟล์ที่เขียนเป็น PHP อ่านออกธรรมดา ไม่มี eval ไม่มี base64 ไม่มีอะไรเลยที่ pattern ตระกูลอำพรางจะสะดุด หน้าตามันเหมือนแอปจัดการไฟล์ธรรมดาตัวหนึ่ง ข้าง ๆ กันมีสคริปต์อัปโหลดที่ชื่อไฟล์เป็นตัวอักษรสุ่ม 8 ตัว กระจายอยู่ในหลายโฟลเดอร์ รวมถึงโฟลเดอร์ที่ตั้งชื่อเลียนแบบไฟล์ระบบหรือโฟลเดอร์มาตรฐานของโฮสติ้ง เพื่อให้คนที่เดินผ่านไม่สงสัย
สาเหตุที่รอบแรกพลาด ผมสันนิษฐานเอาเองว่า (นี่คือการอนุมาน ไม่ใช่ข้อเท็จจริงที่ยืนยันแล้ว) pattern ที่ใช้มองหาการอำพราง ส่วนไฟล์พวกนี้ไม่ได้อำพรางอะไรเลย มันแค่ทำงานของมันตรง ๆ
ผมเคยคิดว่า "ถ้าไม่มี eval กับ base64 ก็ไม่ใช่เชลล์" ซึ่งผิด ตัวที่อ่านออกง่ายและทำงานตรง ๆ ก็เป็นช่องทางเข้าได้เท่ากัน ส่วนที่ต้องมองคือสิ่งที่ไฟล์ทำ และตำแหน่งที่มันวางอยู่ ไม่ใช่หน้าตาว่าดูน่ากลัวหรือเปล่า

รอบสอง สร้างสแกนเนอร์จากตัวอย่างที่เจอจริง
ผมเลยเปลี่ยนวิธีคิด จากการค้นด้วยลายเซ็นที่คนอื่นรวบรวมไว้ มาเป็นสแกนเนอร์ที่ผมเขียนเองจากของจริงที่เจอบนเครื่องผม หลักการมีไม่กี่ข้อ อ่านเฉพาะ 8 KB แรกของทุกไฟล์ (เชลล์ส่วนใหญ่แสดงเจตนาตั้งแต่หัวไฟล์) ใช้ regex เป็นกฎ แต่ละกฎมีระดับ HIGH หรือ MED และทุกกฎต้องมาจากตัวอย่างที่เห็นด้วยตา ไม่เอามาลอย ๆ
#!/usr/bin/env python3
# header scanner (ตัวอย่างย่อ) อ่านหัวไฟล์ 8 KB แล้วเทียบกฎ
import os, re, sys
HEAD = 8192
RULES = [ # (ระดับ, ชื่อกฎ, regex) ตัวอย่างแบบตำรา ไม่ใช่ลายเซ็นของไฟล์จริง
("HIGH", "eval-decode-from-request", re.compile(rb"eval\s*\(\s*(base64_decode|gzinflate)\s*\(\s*\$_(GET|POST|REQUEST)")),
("HIGH", "exec-from-request", re.compile(rb"\b(system|exec|passthru|shell_exec)\s*\(\s*\$_(GET|POST|REQUEST)")),
("HIGH", "request-var-as-function", re.compile(rb"\$_(GET|POST|REQUEST)\s*\[[^\]]+\]\s*\(")),
("HIGH", "preg-replace-e-modifier", re.compile(rb"preg_replace\s*\(\s*['\"].*/[a-z]*e[a-z]*['\"]")),
("MED", "upload-keeps-raw-name", re.compile(rb"move_uploaded_file\s*\([^,]+,\s*[^)]*\$_FILES\[[^\]]+\]\[['\"]name['\"]\]")),
]
SKIP_DIRS = {"vendor", "node_modules", "Maildir", "cache"}
IMG = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".ico")
def hits(path):
with open(path, "rb") as f:
head = f.read(HEAD)
if b"//ICB0" in head[:16]: # ไฟล์เข้ารหัส ionCube ของจริง ข้าม
return []
return [(lvl, name) for lvl, name, rx in RULES if rx.search(head)]
high = 0
for root, dirs, files in os.walk(sys.argv[1]):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for fn in files:
p = os.path.join(root, fn)
low = fn.lower()
is_php = low.endswith((".php", ".phtml", ".php5", ".phar"))
if low.endswith(IMG) or not (is_php or low.endswith(".txt")):
continue
h = hits(p)
if re.fullmatch(r"[0-9a-f]{8,}\.php", low): # ชื่อสุ่มแบบ hex
h.append(("MED", "hex-named-php"))
if is_php and any(part.startswith(".") for part in root.split(os.sep)):
h.append(("MED", "php-in-dot-directory")) # php ในโฟลเดอร์ชื่อขึ้นต้นด้วยจุด
for lvl, name in h:
print(f"{lvl}\t{name}\t{p}")
high += lvl == "HIGH"
sys.exit(1 if high else 0)
เรื่องที่ทำให้ผมเลือกอ่านแค่ 8 KB แรกมีสองข้อ ข้อแรกคือเวลา ทั้ง /home มีไฟล์กว่าล้านไฟล์ ถ้าอ่านทุกไฟล์จนจบ เครื่องที่ลูกค้าใช้งานอยู่จะหนักเกินไป ข้อสองคือเชลล์ส่วนใหญ่แสดงเจตนาตั้งแต่ต้นไฟล์ ทั้งส่วนหัวที่โหลดส่วนประกอบ ส่วนที่รับค่าจาก request และส่วนที่เรียกฟังก์ชันอันตราย ยกเว้นเชลล์ที่แอบไว้ท้ายไฟล์ใหญ่ ๆ ซึ่งกฎแบบนี้จะพลาด ผมยอมรับข้อจำกัดนี้ และถือว่า ClamAV อีกชั้นที่อ่านทั้งไฟล์ช่วยปิดช่องนั้น
สังเกตกฎสองตัวท้ายที่ไม่ได้ดูเนื้อหา แต่ดู "ตำแหน่ง" และ "ชื่อ" ของไฟล์ ไฟล์ PHP ที่อยู่ในโฟลเดอร์ชื่อขึ้นต้นด้วยจุด หรือชื่อเป็นตัวสุ่มยาวแปดตัว เป็นสัญญาณที่ใช้ได้ดีกว่าเนื้อหาในหลายเคส นอกจากนั้นสแกนเนอร์ข้ามไฟล์ที่ ionCube เข้ารหัสไว้ (ดูจากเครื่องหมายหัวไฟล์) ข้ามโฟลเดอร์ Maildir, vendor, node_modules กับ cache ของเฟรมเวิร์ก ถือว่านามสกุลรูปไม่ใช่โค้ด ยกเว้นไฟล์ .txt ที่ผมยังเปิดดู และถ้ามี HIGH อย่างน้อยหนึ่งรายการสคริปต์จะ exit ด้วยรหัส 1 เพื่อให้เอาไปผูกกับงานอัตโนมัติได้
ก่อนเชื่อสแกนเนอร์ผมทดสอบความครอบคลุมก่อนว่าตัวที่ล็อกไว้แล้วในรอบแรก ถูกกฎรอบสองจับได้ครบทุกตัวหรือเปล่า คำตอบคือครบ แปลว่ากฎใหม่ครอบของที่รู้แล้ว และเพิ่มของที่รอบแรกพลาด ผมรันทั้ง /home ได้ไฟล์กว่าล้านไฟล์ และรัน ClamAV กับทุกโดเมนซ้ำอีกชั้น ซึ่ง ClamAV เจอเพิ่มอีกหนึ่งรายการจริง เป็นหน้าเก่าที่ซ่อน iframe ชี้ไปโดเมนมัลแวร์
การที่สแกนเนอร์คืนค่า exit code 1 เมื่อเจอ HIGH ทำให้เอาไปผูกกับงานอัตโนมัติได้ แต่ผมไม่ให้มันล็อกไฟล์เองเด็ดขาด ต้องมีคนอ่านผลก่อนเสมอ ด้วยเหตุผลเรื่อง false positive ข้างล่าง
อ่านทุก hit ก่อนล็อก เพราะ false positive มีจริง
สแกนเนอร์ที่ไวจะมีของที่จับผิดปนมา ผมเปิดอ่านทุก hit ด้วยตาก่อนตัดสินใจ ตัวอย่างที่ไม่ใช่เชลล์ คือไฟล์ readme .txt ที่ในเนื้อหาเอาโค้ด PHP มาเป็นตัวอย่างอธิบาย, ไฟล์ auto-prepend ของ WAF ที่ติดตั้งไว้ในบัญชี และโค้ดไลบรารีเก่าที่ใช้ preg_replace กับ modifier /e ตามประสาไลบรารีก่อน PHP 7 ทั้งหมดนี้ผมแยกออกจากรายการล็อก
ส่วนตัวผมไม่ยอมให้สคริปต์ล็อกอัตโนมัติจากผลสแกนโดยตรง ต้องมีคนอ่านก่อนเสมอ เพราะถ้าล็อกพลาดตัวที่เว็บเรียกใช้จริง เว็บของลูกค้าก็ล้ม และเรากำลังแก้เรื่องความปลอดภัยอยู่ ไม่ใช่สร้างปัญหาใหม่
ขั้น LOCK บันทึกสิทธิ์ แล้ว chmod 000 ไว้ที่เดิม
ลำดับที่ผมใช้คือล็อกก่อน ย้ายทีหลัง สคริปต์ lockdown รับรายการไฟล์ที่คนตรวจแล้ว เขียน files.tsv ที่เก็บ mode, owner, sha256 และพาธเดิมของแต่ละไฟล์ พร้อม RESTORE.md บอกวิธีคืนไฟล์ แล้วค่อยสั่ง chmod 000
#!/bin/bash
# lockdown: อ่านรายการพาธ (ตรวจด้วยตาแล้ว) บันทึกสถานะเดิม แล้วล็อก
LIST=$1
OUT=/root/quarantine/lock-$(date +%F)
mkdir -p "$OUT" && chmod 700 "$OUT"
while IFS= read -r f; do
[ -f "$f" ] || continue
printf '%s\t%s\t%s\t%s\n' \
"$(stat -c %a "$f")" "$(stat -c %U:%G "$f")" \
"$(sha256sum "$f" | cut -d' ' -f1)" "$f" >> "$OUT/files.tsv"
chmod 000 "$f"
done < "$LIST"
cat > "$OUT/RESTORE.md" <<'EOT'
คืนไฟล์: อ่าน files.tsv (mode, owner, sha256, path) แล้ว chmod/chown ตามคอลัมน์
ตรวจ sha256 ให้ตรงก่อนคืนทุกครั้ง
EOT
เหตุผลที่ผมเลือก chmod 000 ก่อน ไม่ลบเลย มีสามข้อ ข้อแรก ผู้โจมตีเสียทางเข้าทันทีเพราะเว็บเซิร์ฟเวอร์อ่านไฟล์ไม่ได้ ข้อสอง เว็บที่ไม่ได้อ้างถึงไฟล์นั้นยังทำงานต่อได้ ลูกค้าไม่ต้องหยุดทำธุรกิจ ข้อสาม เจ้าของบัญชียังขอให้ผมเปิดดูได้ถ้าเขาสงสัยว่าไฟล์นั้นเป็นของเขา

ขั้น QUARANTINE เข้าที่ของ root พร้อม MANIFEST
เมื่อเจ้าของงานตกลงแล้ว ผมย้ายไฟล์ที่ล็อกไว้เข้า /root/quarantine/DATE-webshells/ โดยคงโครงสร้างพาธเดิมไว้ข้างใต้ เปลี่ยน owner เป็น root:root สิทธิ์ 600 ส่วนโฟลเดอร์ 700 ตรวจ sha256 อีกรอบว่าไฟล์ที่ย้ายตรงกับตอนบันทึก แล้วค่อยเขียน MANIFEST.tsv (mode, owner, sha256, พาธเดิม) พร้อมโน้ตวิธีนำกลับ
# quarantine: ย้ายเฉพาะที่ sha256 ตรงกับ files.tsv
Q=/root/quarantine/$(date +%F)-webshells
mkdir -p "$Q" && chmod 700 "$Q"
while IFS=$'\t' read -r mode owner sha path; do
[ -f "$path" ] || continue
[ "$(sha256sum "$path" | cut -d' ' -f1)" = "$sha" ] || { echo "[SKIP] hash เปลี่ยน $path"; continue; }
dest="$Q$path"
mkdir -p "$(dirname "$dest")"
mv "$path" "$dest"
chown root:root "$dest"; chmod 600 "$dest"
printf '%s\t%s\t%s\t%s\n' "$mode" "$owner" "$sha" "$path" >> "$Q/MANIFEST.tsv"
done < files.tsv
find "$Q" -type d -exec chmod 700 {} +
ผลรวมคือมีรายการล็อกยี่สิบกว่ารายการ ย้ายเข้า quarantine เกือบทั้งหมด ส่วนที่เหลือเจ้าของสั่งให้ลบไปก่อนหน้า ผมจัดกลุ่มเป็นตารางสรุปให้ผู้ดูแลอ่านด้วยตามประเภท เพื่อให้คนที่ไม่ได้เห็นไฟล์จริงเข้าใจว่าเกิดอะไรขึ้นบ้าง
ถ้าวันหนึ่งต้องคืนไฟล์ใดไฟล์หนึ่งกลับ (เช่นเจ้าของยืนยันว่าเป็นไฟล์ของเขาจริง) ผมอ่านบรรทัดของไฟล์นั้นใน MANIFEST.tsv แล้วคืนด้วยค่าเดิมครบทุกคอลัมน์ ตรวจ sha256 ก่อนวางกลับ
# คืนไฟล์เดียวจาก quarantine (รันเป็น root) อ่านค่าจาก MANIFEST.tsv
Q=/root/quarantine/DATE-webshells
P=/home/webuser/domains/example.com/public_html/some/file.php
read -r mode owner sha path < <(awk -F'\t' -v p="$P" '$4==p' "$Q/MANIFEST.tsv")
[ "$(sha256sum "$Q$path" | cut -d' ' -f1)" = "$sha" ] || { echo "hash ไม่ตรง"; exit 1; }
mkdir -p "$(dirname "$path")"
cp -a "$Q$path" "$path" && chown "$owner" "$path" && chmod "$mode" "$path"
ก่อนคืนทุกครั้งผมถามตัวเองว่าไฟล์นี้ควรกลับไปจริงไหม ถ้าไฟล์เคยถูกจัดว่าเป็นเชลล์แล้ว ส่วนใหญ่คำตอบคือไม่ ให้เจ้าของไปสร้างไฟล์ใหม่ที่สะอาดแทน
อีกข้อที่ผมระวังคือไม่ส่งไฟล์เหล่านี้ไปให้บริการสแกนสาธารณะ เพราะไฟล์ของลูกค้าอาจมีข้อมูลของเขาปนอยู่ในโค้ด และ ClamAV บนเครื่องเองก็ให้ผลพอสำหรับงานนี้แล้ว นี่เป็นความเห็นส่วนตัวของผม ใครจะทำต่างจากนี้ก็ตามสะดวก
- บันทึก mode, owner, sha256 และพาธเดิมลง files.tsv ก่อนแตะไฟล์
- chmod 000 ที่เดิม ให้เว็บอื่นยังรัน และเจ้าของยังขอตรวจได้
- อ่านทุกรายการ แยกของจริงออกจาก false positive
- ย้ายเข้า quarantine ของ root ตรวจ sha256 แล้วเขียน MANIFEST.tsv
ปิดช่องทางต่อหลังกัก
กักอย่างเดียวไม่พอ เพราะถ้าช่องที่ปล่อยให้ไฟล์เข้ามายังเปิดอยู่ ของใหม่ก็เข้ามาอีก สิ่งที่ผมทำต่อมีดังนี้ บัญชีเดียวที่เป็นเป้าซ้ำ ผมปิด file_uploads ด้วย php_admin_flag[file_uploads]=off ในไฟล์ pool แบบ custom ของผู้ใช้นั้น บล็อก xmlrpc.php ของบล็อกเก่าตัวหนึ่ง ลบไฟล์ตัวติดตั้งกับไฟล์ zip สำรองที่ดาวน์โหลดได้จาก web root (เจอแบบที่เป็นไฟล์สำรองของปลั๊กอินวางทิ้งไว้เว็บหนึ่ง) และย้ายซากของ WordPress รุ่น 2.x ที่ตกค้างออกไปจากโฟลเดอร์ที่เว็บเรียกได้
ลายเซ็นทั่วไป
จับได้เฉพาะของที่หน้าตาเหมือนมัลแวร์ ได้สิบกว่าตัวในหลายบัญชี แต่พลาดไฟล์ที่เขียนอ่านออกธรรมดา
กฎจากของจริง + ตำแหน่ง
ดูหัวไฟล์ ดูชื่อ ดูโฟลเดอร์ที่วางอยู่ แล้วให้คนอ่านทุก hit ก่อนล็อกและกัก
ส่วนตัวผมสรุปได้สามข้อ หนึ่ง สแกนเนอร์ที่ดีต้องสร้างจากของจริงที่เจอในเครื่องของตัวเอง สอง ล็อกก่อนย้ายเสมอ เพราะทำให้ย้อนกลับได้ทุกขั้น สาม หลังย้ายบัญชีจากเครื่องอื่นให้สแกนทันที ใครมีวิธีที่ละเอียดกว่านี้หรือกฎที่ครอบคลุมกว่าก็ตามสะดวก ผมเอาแค่ชุดนี้ที่ได้ผลกับเครื่องของผม ถ้ามีจุดไหนที่ผมอธิบายตกหล่น เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย