مقدمه: وقتی سایت خودتان علیه شما کار می‌کند

حتماً برای شما هم پیش آمده که ناگهان متوجه رفتار عجیب سایتتان شوید. کلیک‌هایی که خودبه‌خود انجام می‌شوند، منوی ادمین که بالای صفحه ظاهر می‌شود (حتی برای کاربران معمولی)، و گاهی صفحه‌ای که به طور کامل جابه‌جا می‌شود. من چند روز پیش با چنین صحنه‌ای روبرو شدم. بعد از لاگین کردن در محیط مدیریت وردپرس، متوجه شدم که یک کد مزاحم خودش را بین تگ <html><head> و متا تگ ویوپورت جاگذاری کرده بود. این کد باعث می‌شد هر کلیکی روی صفحه، رفتار عجیبی از خود نشان دهد. بدتر از همه، نوار مدیریت وردپرس (wpadminbar) در بالای صفحه برای همه کاربران نمایش داده می‌شد و برخی المنت‌ها idهایی داشتند که شامل عبارت «wp-admin» بودند.

راه‌حل موقت؟ پاک کردن دستی این بخش‌ها از خروجی HTML! اما وقتی تعداد فایل‌ها زیاد باشد (همه فایل‌های .html، .htm، .php، .phtml)، کار طاقت‌فرسا می‌شود. به همین دلیل تصمیم گرفتم دو اسکریپت پایتونی بنویسم: یکی برای پاکسازی خودکار (clean.py) و دیگری برای بازیابی از پشتیبان (back.py).

توجه امنیتی: برای جلوگیری از اجرای ناخواسته این اسکریپت‌ها روی سرور، بهتر است پسوند فایل‌های پایتون را به .txt تغییر دهید. در این مقاله فایل‌ها با نام clean.txt و back.txt ارائه می‌شوند – شما پس از دانلود می‌توانید در صورت نیاز پسوند را به .py برگردانید.

در این مقاله قدم به قدم توضیح می‌دهم که:

  • کد مخرب چه شکلی بود و کجا پنهان می‌شد.
  • اسکریپت clean.txt چگونه کار می‌کند و چه الگوهایی را حذف می‌نماید.
  • اسکریپت back.txt چطور فایل‌های پشتیبان را برگردانده و فایل‌های .bak را پاک می‌کند.
  • آیا آپلود این اسکریپت‌ها در سایتی که از کلودفلر استفاده می‌کند، خطری دارد؟

آشنایی با نوع حمله: کد مخرب درست بعد از <html><head>

وقتی خروجی HTML صفحات را ذخیره می‌کردم (مثلاً با Save As از مرورگر)، می‌دیدم که ساختار فایل این‌گونه شروع می‌شود:

<!DOCTYPE html><html>
	<head>
<link rel="profile" href="https://gmpg.org/xfn/11">
<!-- ده‌ها خط کد جاوااسکریپت عجیب و غریب -->
<!-- و بعد از کلی خط، ناگهان متا تگ viewport می‌آید -->
<meta name="viewport" content="width=device-width, initial-scale=1">

و بعد از آن ادامه محتوای عادی سایت. همچنین یک <div id="wpadminbar"> بزرگ حاوی لینک‌های مدیریت در بالای صفحه برای همه کاربران ظاهر می‌شد. علاوه بر این، المان‌های دیگری هم بودند که id یا class آنها «wp-admin» داشت.

به نظر می‌رسید که هکر موفق شده بود کدهای دلخواه خود را مستقیماً در فایل‌های قالب یا فایل‌های کش شده وردپرس تزریق کند. احتمالاً از طریق یک افزونه آسیب‌پذیر یا تم قدیمی.

نکته جالب: این کد مخرب یک اسکریپت جاوااسکریپت بود که رویدادهای کلیک را رهگیری می‌کرد و احتمالاً کاربر را به سایت‌های دیگر هدایت می‌نمود. به همین دلیل وقتی روی هر جایی از صفحه کلیک می‌کردم، یک پنجره جدید باز می‌شد یا به آدرس عجیبی می‌رفتم.

راه‌حل دستی: حذف بخش‌های آلوده با ویرایش فایل

اولین فکری که به ذهنم رسید این بود که تک‌تک فایل‌ها را باز کنم و:

  • از ابتدا تا متا تگ ویوپورت را پاک کنم (چون کد مخرب بین <head> و viewport بود).
  • هر جایی <div id="wpadminbar"> بود و محتویاتش را حذف کنم.
  • هر تگی که id آن شامل «wp-admin» بود را نیز پاک کنم.

اما خیلی زود متوجه شدم که بیش از ۲۰۰ فایل HTML و PHP در شاخه‌های مختلف وجود دارد. انجام دستی این کار نه‌تنها زمان‌بر بود، بلکه ممکن بود خطای انسانی ایجاد کند. پس تصمیم گرفتم قدرت پایتون را به کار گیرم.

معرفی اسکریپت clean.txt (نسخه امن با پسوند txt)

فایل clean.txt را با دقت طراحی کردم تا عملیات زیر را انجام دهد:

۱. ایجاد پشتیبان خودکار

قبل از هر تغییری، از فایل اصلی یک کپی با پسوند .bak می‌سازد.

۲. جستجوی الگوهای مخرب با عبارت منظم (Regex)

نشانه‌ها عبارتند از:

  • متا تگ ویوپورت (که قرار است از آن به عنوان نقطه شروع استفاده کنیم).
  • وجود <div id="wpadminbar">
  • وجود هر id که شامل کلمه wp-admin باشد.

۳. جایگزینی سربرگ

الگوریتم اصلی به این صورت است:

  • ابتدا متا تگ ویوپورت را پیدا می‌کند.
  • سپس کل محتوای قبل از آن را با <html>\n\t<head> جایگزین می‌کند.
  • اگر متا تگ یافت نشد، یک جایگزین ساده انجام می‌دهد.

۴. حذف نوار ادمین و المان‌های wp-admin

کد کامل clean.txt:

import os
import re

TARGET_EXTS = ('.html', '.htm', '.php', '.phtml')
BACKUP_SUFFIX = '.bak'

RE_VIEWPORT = re.compile(r'<meta\s+name="viewport"\s+content="width=device-width,\s*initial-scale=1"\s*/?>', re.IGNORECASE)
RE_WPADMINBAR = re.compile(r'<div\s+id="wpadminbar"[^>]*>.*?</div>', re.DOTALL | re.IGNORECASE)
RE_ADMIN_ID = re.compile(r'<(\w+)[^>]*\s+id\s*=\s*["\'][^"\']*wp-admin[^"\']*["\'][^>]*>.*?</\1>', re.DOTALL | re.IGNORECASE)

def needs_cleaning(content):
    return (RE_VIEWPORT.search(content) or
            RE_WPADMINBAR.search(content) or
            RE_ADMIN_ID.search(content))

def clean_file(file_path):
    with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
        content = f.read()

    if not needs_cleaning(content):
        return False

    original = content

    match = RE_VIEWPORT.search(content)
    if match:
        content = '<html>\n\t<head>' + content[match.end():]
    else:
        if content.startswith('<!DOCTYPE html><html>'):
            content = '<html>\n\t<head>' + content[21:]

    content = RE_WPADMINBAR.sub('', content)
    content = RE_ADMIN_ID.sub('', content)

    if content == original:
        return False

    with open(file_path, 'w', encoding='utf-8') as f:
        f.write(content)
    return True

def process_directory(root_dir='.'):
    cleaned = 0
    for dirpath, _, filenames in os.walk(root_dir):
        for fname in filenames:
            if fname.lower().endswith(TARGET_EXTS):
                full = os.path.join(dirpath, fname)
                backup = full + BACKUP_SUFFIX

                if not os.path.exists(backup):
                    try:
                        with open(full, 'rb') as src, open(backup, 'wb') as dst:
                            dst.write(src.read())
                        print(f"📁 پشتیبان: {backup}")
                    except Exception as e:
                        print(f"❌ خطا در پشتیبان {full}: {e}")
                        continue

                try:
                    if clean_file(full):
                        print(f"✅ پاکسازی: {full}")
                        cleaned += 1
                    else:
                        print(f"⏩ بدون تغییر: {full}")
                except Exception as e:
                    print(f"❌ خطا در {full}: {e}")
    print(f"🏁 تعداد فایل‌های تغییر یافته: {cleaned}")

if __name__ == '__main__':
    process_directory()

اسکریپت back.txt: بازیابی آرامش با چند خط کد اگر بعد از پاکسازی متوجه شدید که چیزی اشتباه حذف شده، می‌توانید با back.txt همه چیز را به حالت قبل برگردانید.

کد back.txt:

import os

def restore_from_backup(root_dir='.'):
    restored = 0
    for dirpath, _, files in os.walk(root_dir):
        for f in files:
            if f.endswith('.bak'):
                backup = os.path.join(dirpath, f)
                original = backup[:-4]
                try:
                    with open(backup, 'rb') as b, open(original, 'wb') as o:
                        o.write(b.read())
                    os.remove(backup)
                    print(f"♻️  بازیابی: {original}")
                    restored += 1
                except Exception as e:
                    print(f"❌ خطا در {backup}: {e}")
    print(f"✅ تعداد {restored} فایل بازیابی شد.")

if __name__ == '__main__':
    restore_from_backup()

نکته امنیتی: چرا پسوند را به txt تغییر دادم؟

برای جلوگیری از اجرای تصادفی این اسکریپت‌ها روی سرور (مثلاً اگر سرور شما به گونه‌ای تنظیم شده باشد که فایل‌های .py را اجرا کند)، بهتر است آن‌ها را با پسوند .txt ذخیره کنید. هنگام استفاده، ابتدا فایل را روی کامپیوتر شخصی خود دانلود کنید، پسوند را به .py تغییر دهید و سپس در محیط خط فرمان اجرا نمایید. هرگز فایل با پسوند .py را روی هاست عمومی رها نکنید.

لینک‌های دانلود مستقیم فایل‌ها

برای سهولت در استفاده، دو فایل زیر را آماده کرده‌اید. کافی است روی لینک کلیک کرده و فایل را ذخیره کنید. سپس پسوند فایل را از .txt به .py تغییر دهید و مطابق دستورالعمل اجرا نمایید.