حل یک اشکال حیاتی در کتابخانه پیشفرض ذخیرهسازی Rails

یک اتفاق عجیب
در 20 مارس، کاربران ChatGPT گزارش دادند که مکالماتی را مشاهده کردهاند که متعلق به خودشان نبوده است. فقط چند هفته قبل من یک اشکال را حل کرده بودم که در آن کتابخانه پیشفرض ذخیرهسازی Rails (Dalli) مقادیر نادرست را برمیگرداند. من فکر کردم، “این رخداد ChatGPT بسیار شبیه اتفاقاتی است که می تواند با آن اشکال حافظه پنهان از طریق بازگرداندن HTML ذخیره شده نادرست رخ دهد.” اما OpenAI از Rails استفاده نمیکند، بنابراین من این فکر را بهعنوان سوگیری اخیر رد کردم.
وقتی مرگ پس از مرگ را دیدم فکم افتاد – دقیقاً همان مفهوم اشکال بود، فقط در یک کتابخانه دیگر! یادآوری اینکه چیزهای سخت اغلب از زبان ها و کتابخانه های خاصی فراتر می روند. و پسر، آیا این یک اشکال سخت است؟ این در تقاطع حافظه پنهان، مدیریت منابع مشترک و فساد دولتی قرار دارد – فضاهای مشکل ساز بدنام.
مکانیسم اشکال
کلاینت کش Dalli با یک سوکت به یک سرور حافظه پنهان متصل می شود (که در اصل فقط یک بافر است، من از این اصطلاحات به جای یکدیگر استفاده خواهم کرد). مشتری یک پیام دریافت را به سرور ارسال می کند که می گوید “مقدار این کلید را به من بدهید”. در پاسخ، سرور مقداری داده را در بافر سوکت می نویسد. سپس کلاینت بایتهایی را از سوکت میخواند تا زمانی که یک پاسخ کامل را پردازش کند، و این فرض را ایجاد میکند که آنچه میخواند، پاسخ به پیام دریافتی خودش است. اگر سوکت در ابتدای عملیات خالی بود، این فرض کار می کند و کلاینت مقدار کش صحیح را برمی گرداند.
اما اگر بافر بود چه؟ نه وقتی مشتری دستور get را صادر کرد خالی باشد؟ سپس کلاینت دادههایی را که روی سوکت قرار دارد پردازش میکند، گویی مقدار درخواستی است، و – از آنجایی که هیچ مرحله اعتبارسنجی اضافی رخ نمیدهد – مقدار اشتباهی را برای کلید داده شده برمی گرداند. بدتر از آن، چون فقط یک مقدار داده را از بافر می خواند (می داند چه زمانی خواندن را بر اساس سرصفحه ابرداده متوقف کند)، پاسخ واقعی به درخواست آن در سوکت باقی می ماند تا درخواست بعدی نادرست برگردد، و به زودی.
بنابراین سوکت وارد یک وضعیت فاسد “خاموش توسط یک” نامحدود شده است. به این معنی که عملیات n ام مقدار کلید عملیات n-1 را برمی گرداند و مقدار خود را در بافر برای خواندن n+1 باقی می گذارد. وای نه! آن وضعیت خراب در یک کتابخانه دیگر، حادثه ChatGPT را توضیح می دهد.
اگر سیستم حافظه پنهان شما شروع به بازگرداندن مقادیر اشتباه کند، از منظر امنیتی بسیار ترسناک است، زیرا داده ها یا HTML ذخیره شده برای یک کاربر ممکن است به کاربر دیگر نشان داده شود. در موردی که من به طور مستقیم دیدم این اتفاق نیفتاد زیرا مقادیر نادرست حافظه پنهان همه نوع با هم مطابقت نداشتند و باعث میشد فرآیند به طور مکرر با خطا مواجه شود تا زمانی که از بین برود. این خوش شانس بود، اما برنامه Rails می توانست دقیقاً نوع حادثه ای را که OpenAI دیده است، ببیند. این فقط بستگی به نوع دادههای موجود در سوکت خراب و نحوه استفاده برنامه از حافظه پنهان دارد.
چگونه اتفاق می افتد
چرا یک مقدار خوانده نشده در بافر وجود دارد؟ تنها چیزی که مورد نیاز است این است که کلاینت یک فرمان get را به سرور ارسال کند و سپس نتواند پاسخ را بخواند – در حالی که سوکت را برای یک درخواست آتی برای استفاده ترک کنید.
یکی از راه هایی که ممکن است در Dalli اتفاق بیفتد این است که مشکلی در آن وجود داشته باشد get_multi
کد اگر مشتری چندین مقدار را درخواست کند، برخی از زیرمجموعههای آن مقادیر را از سوکت بخواند، و سپس قبل از اینکه سرور نوشتن همه مقادیر را در سوکت به پایان برساند، ممکن است رخ دهد. راه دیگری که ممکن است این اتفاق بیفتد این است که چیزی – مثلاً یک خطا یا یک مهلت زمانی – در اجرای کد مشتری بین صدور یک پیام دریافت و خواندن پاسخ قطع شود.
در حالت ایدهآل، کلاینت در صورت بروز خطا یا مهلت زمانی، سوکت را دور میاندازد/بازنشانی میکند، و در اکثر سناریوها دقیقاً همین اتفاق میافتد. اما تنها چیزی که لازم است یک مسیر کد است که در آن سوکت می تواند خراب شود و به اطراف بچسبد. متأسفانه، حداقل یکی از چنین مسیرهای کدی وجود دارد که رفتار نادرست حافظه پنهان را که مشاهده کردم توضیح می دهد.
جزئیات کمتر از این سؤال است که آیا مشتری باید کاملاً بر این فرض تکیه کند که سوکت هنگام شروع عملیات دریافت خالی است یا خیر.
رفع
بهعنوان یک مهندس امنیت، وقتی میتوانم یک باگ را در عمق یک انتزاع حل کنم، دوست دارم، و توسعهدهندهای که از انتزاع استفاده میکند امکان ایجاد دوباره باگ را غیرممکن میکند – حتی اگر اشتباه کند. ما باید توسعه دهندگان را تشویق کنیم که از خطرات شناخته شده اجتناب کنند، اما بهتر است که خود یک انتزاع به طور خودکار از نتایج غیرقابل قبول جلوگیری کند. یا، در انتزاع، باید سعی کنیم حالت خود را به درستی مدیریت کنیم، اما بهتر است حتی اگر نقصی وجود داشته باشد و حالتی غیرمنتظره ظاهر شود، بتوانیم از بدترین تأثیرات جلوگیری کنیم. به جای تکیه بر همه کدهای سطح بالاتر که هر بار کار درست را انجام می دهند، بسیار قوی تر است که یک مورد شکست را در ریشه غیرممکن کنیم.
برای این منظور، من یک راه حل اساسی ساده را دنبال کردم. Memcached دارای یک getk
فرمان، که با get
با برگرداندن کلید در کنار مقدار. نکته منفی هزینه عملکرد کمی است – بازگرداندن کلید به معنای پردازش بایت های بیشتر است. نکته مثبت این است که کلاینت هم کلید و هم مقدار را از memcached پس می گیرد و می تواند بررسی کند که کلید با آنچه خواسته است مطابقت داشته باشد.
این هسته اجرای من است – اگر کلیدها مطابقت نداشتند، خطا را مطرح کنید. ساده. حالا حتی اگر بافر حاوی مقدار باقیمانده برای یک کلید دیگر باشد، مشتری آن را بر نمی گرداند. در عوض بازنشانی میشود و دوباره امتحان میکند.
تجزیه و تحلیل رفع
هزینه در مقابل سود چیست؟ در زمینه برنامه Rails چند مستاجر، عموماً خطر قابل قبولی نیست که به طور بالقوه داده های یک مستاجر را در معرض دیگری قرار دهیم، حتی در یک رویداد با احتمال کم. افزایش اندک سربار برای رد کامل باگ قرار گرفتن در معرض داده، هزینه کمی است.
در این صورت سربار است واقعا کم اهمیت. زمانی که من این پیاده سازی را در برنامه ای که روزانه میلیون ها درخواست را ارائه می داد، اجرا کردم هیچ تاثیر قابل اندازه گیری بر عملکرد. حتی اگر سربار قابل توجه بود، باز هم پرداخت هزینه برای اکثر مدل های تهدید ارزشمند است.
میتوان تصور کرد که برنامهای که از سرویس گیرنده حافظه پنهان استفاده میکند، ممکن است محاسبه هزینه/فایده متفاوتی انجام دهد و تصمیم بگیرد که عملکرد مهمتر است و شکستهای یکپارچگی کلید: ارزش قابل قبول است. به همین دلیل است که یکی از رویکردهای پیشنهادی من این بود که آن را به یک گزینه پیکربندی در روابط عمومی بالادستی تبدیل کنم. متأسفانه نگهدارنده موافقت نکرد که مشتری اصلاً این مشکل را حل کند، علیرغم گزارش های متعدد دیگری که در سیستم های تولید اتفاق می افتد.
نگهدارنده نکات معتبری داشت – مهلت زمانی لایه برنامه در Ruby خطرناک است، و اگر از آنها استفاده می کنید، احتیاط زیادی لازم است. برای جلوگیری از اشکالات فساد دولتی مانند این، این فرآیند را به طور کامل پس از یک بازه زمانی از بین ببرید.
با این وجود، من کاملاً معتقدم که با توجه به شدت این موضوع، ارزش دارد که خود مشتری از وقوع بدترین تأثیر جلوگیری کند. من همچنین متقاعد نشده ام که تایم اوت ها تنها راه رخ دادن این باگ هستند. مسئله get_multi را ببینید یا احتمال تغییر کد در آینده را در نظر بگیرید که یک اشکال مدیریت اتصال را معرفی می کند.
جایگزین، گزینه ها
راه دیگر برای حل مشکل این است که سوکت را تا زمانی که پاسخ خوانده شود قفل نگه دارید. به این ترتیب، یک سوکت با یک پاسخ خوانده نشده توسط عملیات بعدی دوباره استفاده نمی شود. من مطمئن نیستم که چرا Dalli قبلاً به این روش کار نمی کند، اما در حال حاضر قفل را پس از صدور دستور get آزاد می کند و قبل از خواندن پاسخ دوباره آن را دریافت می کند. من یک PR دوم را باز کردم و پیشنهاد دادم که قفل روی سوکت را برای کل دنباله دریافت نگه دارم، که آن هم رد شد.
را safe_get
پیاده سازی همچنان دارای یک مزیت است که بدون توجه به اینکه سوکت به درستی مدیریت می شود یا حتی اگر memcached پاسخ های اضافی ارسال می کند، کار می کند. این رویکرد برای عموم در دسترس است و تولید آزمایش شده است. لطفاً اگر سؤال یا بازخوردی در مورد آن دارید به من اطلاع دهید!