Copy Fail (CVE-2026-31431): وقتی رم دروغ می‌گه

Copy Fail (CVE-2026-31431): وقتی رم دروغ می‌گه

در روز بیست و نهم آوریل ۲۰۲۶، تیم تحقیقاتی Xint Code یک آسیب‌پذیری رو عمومی کرد که از حدود سال ۲۰۱۷ در هسته لینوکس پنهان بوده. نه به خاطر اینکه کسی آن رو مخفی کرده بود، بلکه چون در تقاطع سه تصمیم مستقل و به ظاهر بی‌خطر نشسته بود و هیچ‌کس آن سه رو کنار هم ندیده بود. این باگ به نام Copy Fail شناخته می‌شه و شناسه امنیتی‌اش CVE-2026-31431 هست.

نتیجه عملی‌اش ساده و ترسناک هست: یک کاربر کاملاً معمولی، بدون هیچ دسترسی خاص، بدون نصب هیچ چیز، با یک اسکریپت ۷۳۲ بایتی پایتون که فقط از کتابخانه‌های استاندارد استفاده می‌کنه، می‌تونه روی اوبونتو، ردهت، آمازون لینوکس و SUSE به root برسد. همان اسکریپت، بدون تغییر، روی همه آن‌ها.

وقتی اولین بار گزارشش رو خواندم، چند بار برگشتم و دوباره خواندم. چون ساختار باگ به شکل غیرمعمولی تمیز بود. نه race condition، نه heap spray، نه timing attack. فقط سه قطعه از کرنل که هر کدام به تنهایی کاملاً درست کار می‌کنن، اما کنار هم یک حفره می‌سازن که تقریباً یک دهه هیچ‌کس آن رو ندیده بود.


اول از همه: page cache چیست و چرا مهم هستش؟

قبل از هر چیز باید یک مفهوم اساسی رو بدانید که بدون آن بقیه ماجرا معنا نداره.

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

وقتی فایلی برای اولین بار باز می‌شه، کرنل محتوایش رو از هارد می‌خونه و یک نسخه رو در این ناحیه که page cache نام داره ذخیره می‌کنه. دفعه بعد که همان فایل لازم بشه، کرنل دیگر به هارد نمی‌ره. همان نسخه‌ای رو که در رم دارد می‌ده.

این page cache بین همه چیز روی سیستم مشترک هست. وقتی شما /usr/bin/ls رو اجرا می‌کنید، کرنل آن رو در page cache می‌ذاره. وقتی نفر دیگری همان دستور رو اجرا بکنه، از همان صفحات رم می‌خونه. وقتی سرویس دیگری در پس‌زمینه همان فایل رو باز بکنه، باز هم همان صفحات.

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

این جمله اخیر قلب Copy Fail هست.


مفهوم دوم: فایل‌های setuid

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

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

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

ls -la /usr/bin/su
# -rwsr-xr-x 1 root root 67816 ...

آن s در جای x یعنی setuid فعال هست.

حالا سوال اینجاست: اگر کسی بتواند محتوای /usr/bin/su رو در رم، در همان page cache، با کد دلخواه خودش جایگزین بکنه چه می‌شه؟ وقتی آن فایل اجرا شود، کد مهاجم با دسترسی root اجرا می‌شه. هیچ رمزی لازم نیست.


سه قطعه که به تنهایی تنها بی‌خطرند

قطعه اول: AF_ALG

کرنل لینوکس داخل خودش الگوریتم‌های رمزنگاری زیادی دارد که برای کارهای داخلی استفاده می‌کنه. رمزنگاری دیسک، پروتکل‌های شبکه، تأیید هویت، و کارهای دیگر. در سال ۲۰۱۰ یک interface اضافه شد به اسم AF_ALG که این الگوریتم‌ها رو از طریق socket به برنامه‌های فضای کاربر هم می‌ده.

ایده ساده هست. یک برنامه می‌تونه یک socket باز بکنه، بگه «می‌خواهم از AES استفاده کنم»، داده بدهد، و نتیجه رمزنگاری شده بگیرد. بدون اینکه خودش الگوریتم رو پیاده‌سازی بکنه. کرنل این کار رو انجام می‌ده.

نکته مهم: هیچ دسترسی خاصی لازم نیست. هر کاربر معمولی می‌تونه AF_ALG رو استفاده بکنه. این یک تصمیم طراحی آگاهانه بود چون هدف این بود که برنامه‌ها بتوانند از رمزنگاری سخت‌افزاری کرنل استفاده کنند بدون اینکه به دسترسی خاصی نیاز داشته باشن.

قطعه دوم: تابع splice

splice() یک syscall هست که داده رو بین دو file descriptor جابجا می‌کنه، اما به شکل بسیار هوشمندانه‌ای. به جای اینکه داده رو واقعاً کپی بکنه، فقط اشاره‌گرها رو جابجا می‌کنه.

فرض کنید می‌خواهید محتوای یک فایل رو به یک socket بدهید. روش معمول این هست که محتوا رو در یک بافر بخوانید و بعد از آن بافر به socket بنویسید. دو بار کپی. splice این کار رو نمی‌کنه. می‌گه: «آن صفحه‌ای که فایل در page cache دارد، همان رو مستقیماً به مقصد بده.» هیچ کپی واقعی اتفاق نمی‌افته.

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

وقتی AF_ALG و splice کنار هم قرور می‌گیرند، می‌توان صفحات page cache یک فایل رو مستقیماً وارد یک عملیات رمزنگاری کرد. عملیات رمزنگاری کرنل روی همان صفحات واقعی فایل انجام می‌شه. اگر آن عملیات به هر دلیلی چیزی در آن صفحات بنویسد، مستقیماً روی page cache فایل هدف نوشته شده.

قطعه سوم: الگوریتم authencesn و یک اشتباه پنهان

این قطعه کمی فنی‌تر هست اما مهم‌ترین بخش داستان هست.

authencesn یک الگوریتم رمزنگاری در کرنل هست که برای پروتکل شبکه‌ای IPsec طراحی شده، مشخصاً برای پشتیبانی از شماره‌های توالی ۶۴ بیتی. IPsec برای اینکه بداند بسته‌های شبکه به ترتیب درست می‌رسن، از یک شماره توالی استفاده می‌کنه. نسخه قدیمی ۳۲ بیتی بود. نسخه جدید ۶۴ بیتی هست که authencesn آن رو مدیریت می‌کنه.

این شماره ۶۴ بیتی به دو نیمه ۳۲ بیتی تقسیم می‌شه: یک نیمه بالا (seqno_hi) و یک نیمه پایین (seqno_lo). در پروتکل شبکه فقط نیمه پایین در بسته وجود داره. نیمه بالا implicit هست و باید محاسبه شود.

برای اینکه checksum درستی حساب بکنه، authencesn باید این بایت‌ها رو در ترتیب خاصی کنار هم بذاره. برای این کار از یک ترفند استفاده می‌کنه: از همان بافر خروجی به عنوان فضای موقتی برای جابجایی بایت‌ها استفاده می‌کنه. این ترفند خودش مشکل نداره، اما یک جزئیات کوچک دارد.

خط سوم این کد:

scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1);

چهار بایت رو در آفست assoclen + cryptlen می‌نویسه. این آفست از مرز خروجی مجاز بیرون هستش. الگوریتم داره توی جایی می‌نویسه که متعلق به اون نیست. بعد هم، اگر عملیات شکست بخوره، آن چهار بایت برگردانده نمی‌شن و برای همیشه نوشته شده‌اند.

در حالت عادی این مشکل عملی ایجاد نمی‌کنه چون آن ناحیه اضافه یک بافر معمولی هست و کسی توجهی به آن نداره. اما اگر صفحات page cache آنجا باشند، آن چهار بایت مستقیماً روی فایل هدف در رم نوشته می‌شن.


تقاطع: چطور این سه قطعه یک باگ می‌سازن

در سال ۲۰۱۷ یک بهینه‌سازی به algif_aead.c اضافه شد. قبل از آن، AF_ALG عملیات AEAD رو به صورت out-of-place انجام می‌داد، یعنی بافر ورودی و خروجی کاملاً جدا بودن. این بهینه‌سازی عملیات رو in-place کرد تا سریع‌تر باشد.

در حالت in-place، کرنل داده AAD و ciphertext رو از scatterlist ورودی کپی می‌کنه، اما برای تگ احراز هویت به جای کپی، صفحات رو با sg_chain() مستقیماً به scatterlist خروجی وصل می‌کنه. بعد req->src = req->dst می‌ذاره. این یعنی ورودی و خروجی به یک scatterlist واحد اشاره می‌کنن که در انتهایش صفحات page cache فایل هدف آویزان هست.

Input SGL:   [ AAD ] [ Ciphertext ] [ Auth Tag ]
                                          |
                                          | (sg_chain: no copy, direct reference)
                                          |
Output SGL:  [ AAD ] [ Ciphertext ] -----+-----> [ page cache of target file ]
                   ^
                   |
             req->src = req->dst (same scatterlist)

حالا وقتی authencesn می‌آد و چهار بایت رو در آفست assoclen + cryptlen می‌نویسه، آن آفست دقیقاً روی همان صفحات page cache می‌افته. کرنل جلویش رو نمی‌گیره. هیچ‌چیز در API نگفته که این کار ممنوعه. هیچ assertion، هیچ boundary check، هیچ مستنداتی که این invariant رو مشخص کرده باشه.

هر سه قطعه کار خودشان رو درست انجام می‌دن. مشکل فقط در تقاطعشان هست.


مهاجم دقیقاً چه چیزی رو کنترل می‌کنه؟

این بخش خوب ماجراست. مهاجم سه پارامتر کامل در اختیار دارد:

کدام فایل هدف باشد: هر فایلی که کاربر اجازه خواندن آن رو داشته باشه. /usr/bin/su روی تقریباً تمام توزیع‌های لینوکس قابل خواندن توسط همه هست. می‌توانید با ls -la /usr/bin/su این رو تأیید کنید.

کدام چهار بایت هدف باشند: با انتخاب آفست splice، طول داده ورودی، و مقدار assoclen، مهاجم تعیین می‌کنه که آفست assoclen + cryptlen دقیقاً روی کدام چهار بایت فایل هدف بیفتد. این کنترل کامل هست.

چه مقداری نوشته شود: چهار بایتی که نوشته می‌شن مقدار seqno_lo هستند که از بایت‌های ۴ تا ۷ داده AAD می‌آن. مهاجم کاملاً کنترل دارد که در sendmsg() چه بایت‌هایی رو بفرستد.

این یعنی مهاجم می‌تونه یک حلقه بسازد: چهار بایت بنویس، برو سروغ چهار بایت بعدی. به همین شکل تکراری، یک payload کامل رو در page cache /usr/bin/su می‌نویسه. بعد آن فایل رو اجرا می‌کنه. کرنل نسخه دست‌کاری‌شده رم رو اجرا می‌کنه. چون su یک فایل setuid-root هست، کد مهاجم با دسترسی root اجرا می‌شه.


چرا ابزارهای امنیتی هیچ چیز نمی‌بینن؟

این بخشی هست که Copy Fail رو به خصوص نگران‌کننده می‌کنه.

ابزارهایی مثل AIDE یا Tripwire که برای بررسی یکپارچگی فایل‌ها طراحی شده‌اند، یک hash از محتوای فایل روی دیسک می‌گیرند و با hash قبلی مقایسه می‌کنن. اگر فرق داشت، هشدار می‌دن.

فایل روی دیسک تغییر نکرده. هیچ write ای به هارد نرفته. کرنل صفحه page cache رو هرگز به عنوان «dirty» علامت‌گذاری نمی‌کنه، چون تغییر از مسیر معمول VFS عبور نکرده. صفحه dirty نشده، پس writeback اتفاق نمی‌افته، پس هارد دست نخورده می‌مونه.

AIDE می‌گه همه چیز سالم هست. Tripwire می‌گه همه چیز سالم هست. sha256sum /usr/bin/su hash اصلی رو برمی‌گردونه. حتی اگر بلافاصله بعد از اجرای اکسپلویت آن رو اجرا کنید، نتیجه درست به نظر می‌رسه.

تنها ردپایی که می‌مونه در رم هست، و رم بعد از reboot پاک می‌شه.


مقایسه با Dirty Cow و Dirty Pipe

دو باگ مشهور قبلی در همین حوزه وجود دارن. مقایسه‌شان با Copy Fail نشان می‌ده چرا این باگ جدید از نظر ساختاری متفاوت هست.

Dirty Cow در سال ۲۰۱۶ کشف شد و CVE-2016-5195 شناسه‌اش هست. این باگ هم امکان نوشتن در page cache فایل‌های read-only رو می‌داد، اما از یک race condition در مسیر copy-on-write حافظه مجازی استفاده می‌کرد. یعنی باید دو thread همزمان با timing دقیق روی یک ناحیه حافظه کار می‌کردند. گاهی می‌برد، گاهی می‌باخت، و گاهی سیستم رو crash می‌داد. برای کار کردن نیاز به تلاش‌های متعدد داشت و بر روی بعضی پیکربندی‌ها غیرقابل اعتماد بود.

Dirty Pipe در سال ۲۰۲۲ کشف شد و CVE-2022-0847 شناسه‌اش هست. این باگ پیشرفته‌تر و قابل اطمینان‌تر از Dirty Cow بود، اما به نسخه‌های خاصی از کرنل محدود می‌شد و نیاز به manipulation دقیق ساختار pipe buffer داشت. برای هر توزیع باید جداگانه تنظیم می‌شد.

Copy Fail هیچ race condition نداره. deterministic هست، یعنی هر بار که اجرا شود به همان نتیجه می‌رسه. همان اسکریپت بدون تغییر روی کرنل‌های ۶.۱۲، ۶.۱۷ و ۶.۱۸ روی چهار توزیع مختلف کار می‌کنه. هیچ offset مخصوص توزیع، هیچ recompile، هیچ version check، هیچ تلاش مجدد در صورت شکست.

                   Reliability    Portability    Complexity
Dirty Cow (2016)      Low            Medium         High
Dirty Pipe (2022)     High           Low            Medium
Copy Fail (2026)      High           High           Low

چطور پیدا شد؟

محقق Taeyang Lee از شرکت Theori مدتی روی attack surface مربوط به AF_ALG کار می‌کرد. می‌دانست که splice می‌تونه صفحات page cache رو مستقیماً وارد subsystem رمزنگاری بکنه و این یک ناحیه کمتر بررسی‌شده هست. سپس از ابزار Xint Code برای اسکن خودکار کل زیرسیستم crypto کرنل استفاده کرد با این راهنما که به codepath‌های قابل دسترس از userspace توجه بکنه و به خصوص دنبال جاهایی بگردد که splice می‌تونه page cache references رو وارد crypto TX scatterlist بکنه.

بعد از حدود یک ساعت، Copy Fail بالاترین severity رو در خروجی داشت. باگ از مارس ۲۰۲۶ به تیم امنیتی کرنل گزارش شد، پچ در اوایل آوریل commit شد، و در بیست و نهم آوریل عمومی شد.


رفع موقت

تا وقتی که پچ رسمی کرنل رو نصب کنید، می‌توانید ماژولی که اکسپلویت از آن استفاده می‌کنه رو غیرفعال کنید:

echo "install algif_aead /bin/false" > /etc/modprobe.d/disable-algif-aead.conf
rmmod algif_aead 2>/dev/null

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

این کار AF_ALG رو فقط برای الگوریتم‌های AEAD غیرفعال می‌کنه. بیشتر برنامه‌های معمول از این interface مستقیماً استفاده نمی‌کنن، پس تأثیر جانبی روی سیستم‌های عادی کم هست.

رفع دائمی

پچ رسمی در commit a664bf3d603d اعمال شده. کافی هست بسته کرنل توزیعتان رو آپدیت کنید:

# Ubuntu / Debian
apt update && apt upgrade linux-image-generic

# RHEL / Fedora
dnf update kernel

# SUSE
zypper update kernel-default

# Arch Linux
pacman -Syu linux

بعد از آپدیت reboot کنید. پچ به سادگی عملیات AEAD رو دوباره out-of-place کرد. req->src حالا به TX SGL اشاره می‌کنه که ممکن هست صفحات page cache داشته باشه و req->dst به RX SGL که بافر کاربر هست. مکانیزم sg_chain که صفحات page cache رو به scatterlist خروجی وصل می‌کرد کاملاً حذف شد. ساده‌ترین ممکن، اما کافی.


وقتی این باگ رو نگاه می‌کنید، هیچ بخش بدی در آن نیست. AF_ALG یک تصمیم درست بود. splice یک تصمیم درست بود. حتی بهینه‌سازی in-place سال ۲۰۱۷ در زمان خودش منطقی بود. authencesn هم برای کاری که طراحی شده بود کار می‌کرد.

مشکل فقط اینجاست که هیچ‌کس این سه تصمیم رو کنار هم نگذاشت و نپرسید: «اگر همه این‌ها با هم استفاده شوند چه اتفاقی می‌افته؟» نه به خاطر سهل‌انگاری، بلکه چون هر قطعه توسط تیم‌های مختلف، در سال‌های مختلف، برای اهداف مختلف اضافه شده بود.

Copy Fail یادآوری خوبی هست که آسیب‌پذیری‌های جدی همیشه در کد جدید یا اشتباهات آشکار نیستند. گاهی در تقاطع چند تصمیم قدیمی و منطقی پنهان‌اند و فقط یک نگاه از بیرون می‌تونه آن‌ها رو کنار هم ببیند.