نکات کلیدی
-
لینکسازی پویا در Java شامل بارگذاری کتابخانههای بومی در زمان اجرا است که میتواند تضمینهای ایمنی و کارایی JVM را دور بزند و به ریسکهای امنیتی بالقوه و مشکلات ایمنی حافظه منجر شود.
-
انتقال کد بومی به JVM مزایای آن را حفظ میکند، از جمله توزیع مستقل از پلتفرم و ایمنی در زمان اجرا، اما برای حفظ سرعت توسعه به تلاش قابلتوجهی نیاز دارد.
-
WebAssembly (Wasm) یک جایگزین قابلحمل و امن ارائه میدهد که اجازه میدهد کد بومی بهصورت ایمن درون برنامههای JVM اجرا شود.
-
با استفاده از Chicory، توسعهدهندگان میتوانند کدی که برای Wasm کامپایل شده است، مانند SQLite، را در محیط JVM اجرا کنند و از قابلیت حمل و امنیت بالاتر بهرهمند شوند.
-
مدل sandbox و حافظه Wasm تضمینهای امنیتی قدرتمندی فراهم میکند و از دسترسی غیرمجاز به منابع سیستم و حافظه میزبان جلوگیری میکند.
وقتی در یک اکوسیستم مدیریتشده مانند JVM کار میکنیم، اغلب نیاز داریم کد بومی اجرا کنیم. این معمولاً زمانی اتفاق میافتد که به کد رمزنگاری، فشردهسازی، پایگاه داده، یا شبکهنویسی نوشتهشده به زبان C نیاز دارید.
برای مثال SQLite را در نظر بگیرید؛ طبق ادعای خودش، پرکاربردترین کدبیس است که بهطور گسترده در برنامههای JVM استفاده میشود. اما SQLite به زبان C نوشته شده است، پس چگونه در برنامههای JVM ما اجرا میشود؟
لینکسازی پویا رایجترین روشی است که امروز با این مشکل برخورد میکنیم. دهههاست که این کار را در تمام زبانهای برنامهنویسی انجام دادهایم و بهخوبی هم کار میکند. با این حال، وقتی با JVM استفاده میشود، مجموعهای از مشکلات را ایجاد میکند. راه جایگزین، تا همین اواخر، انتقال کد به یک زبان برنامهنویسی دیگر بود که آن هم چالشهای خاص خودش را دارد.
مشکلات لینکسازی پویا
برای درک مشکلات لینکسازی پویا، مهم است توضیح دهیم که چگونه کار میکند. وقتی میخواهیم مقداری کد بومی اجرا کنیم، ابتدا از سیستم میخواهیم کتابخانه بومی را بارگذاری کند (در اینجا برای سادهسازی از شبهکد Java Native Access یا JNA استفاده میکنیم):
برای یک مدل ذهنی ساده، تصور کنید این کار کد بومی SQLite را از دیسک میخواند و آن را به کد بومی JVM «ضمیمه» میکند.
سپس میتوانیم یک هندل به یک تابع بومی بگیریم و آن را اجرا کنیم:
JNA کمک میکند با نگاشت خودکار انواع Java به انواع C و سپس انجام عکس این کار برای مقادیر بازگشتی.
وقتی sqlite3_open فراخوانی میشود، CPU ما به آن کد بومی میپرد. این کد بومی خارج از تضمینهای JVM اجرا میشود، اما در همان سطح دسترسی قرار دارد. این کد تمام قابلیتها، مجوزها، و دسترسیهای پردازهای را دارد که JVM در آن اجرا میشود. این ما را به اولین مشکل لینکسازی پویا میرساند.
زمان اجرا: فرار از JVM
وقتی در زمان اجرا به کد بومی میپریم، از تضمینهای ایمنی و کارایی JVM خارج میشویم. JVM دیگر نمیتواند در مورد خطاهای حافظه، خطاهای segmentation، قابلیت مشاهدهپذیری، و موارد مشابه به ما کمک کند. همچنین توجه داشته باشید که این کد میتواند تمام حافظه را ببیند و تمام مجوزها و قابلیتهای کل پردازه را دارد. بنابراین اگر یک آسیبپذیری یا payload مخرب وارد شود، ممکن است واقعاً به دردسر بزرگی بیفتید.
ایمنی حافظه بهطور فزایندهای به یک موضوع اساسی برای متخصصان نرمافزار تبدیل شده است. دولت ایالات متحده آسیبپذیریهای حافظه را آنقدر جدی دانسته که شروع به فشار آوردن به فروشندگان برای فاصلهگرفتن از زبانهای غیرایمن از نظر حافظه کرده است. من فکر میکنم شروع پروژههای جدید با زبانهای ایمن از نظر حافظه عالی است. با این حال، معتقدم احتمال اینکه این کدبیسهای بنیادی از C و C++ منتقل شوند کم است و درخواست چنین انتقالی غیرمنطقی است. با این حال، این تلاش معتبر است و ممکن است در نهایت روی کسبوکار شما اثر بگذارد. برای مثال، دولت همچنین در حال بررسی انتقال بخشی از مسئولیت به افرادی است که نرمافزار و خدمات نرمافزاری را مینویسند و اجرا میکنند. اگر این اتفاق بیفتد، ممکن است ریسک مالی و انطباقی اجرای کد بومی به این روش افزایش یابد.
توزیع: اهداف استقرار متعدد
دومین مشکل لینکسازی پویا این است که دیگر نمیتوانیم کتابخانه یا برنامهمان را فقط بهصورت یک فایل jar توزیع کنیم. این بزرگترین مزیت JVM، یعنی توزیع کد مستقل از پلتفرم، را از بین میبرد. حالا باید نسخه بومی کتابخانه را برای هر هدف ممکن کامپایل و همراه برنامه ارسال کنیم. یا باید بار نصب، ایمنسازی، و لینککردن کد بومی را روی دوش کاربر نهایی بگذاریم؟ این کار ما را در معرض دردسرهای پشتیبانی و ریسک قرار میدهد، چون ممکن است کاربر نهایی کامپایل را اشتباه انجام دهد یا کدی از یک منبع نامعتبر یا مخرب استفاده کند.
یک گزینه جایگزین: انتقال به JVM
پس با این مشکل چه کار کنیم؟ هسته مسئله کد بومی است. آیا میتوانیم تمام این کد را به JVM منتقل یا برای JVM کامپایل کنیم؟
انتقال کد به یک زبان مبتنی بر JVM گزینه خوبی است، چون تمام تضمینهای ایمنی و کارایی زمان اجرا را حفظ میکنید. همچنین سادگی زیبای استقرار را حفظ میکنید: میتوانید کد خود را بهصورت یک jar واحد و مستقل از پلتفرم منتشر کنید. نقطهضعف این است که باید کد را از ابتدا بازنویسی کنید و سپس آن را نگهداری کنید. این میتواند تلاش انسانی عظیمی باشد و شما همیشه از پیادهسازی بومی عقب خواهید بود. در روایت SQLite ما، نمونهای از این رویکرد SQLJet است که بهنظر میرسد دیگر نگهداری نمیشود.
کامپایل کد به بایتکد JVM نیز میتواند ممکن باشد، اما گزینهها محدود هستند. زبانهای بسیار کمی JVM را بهعنوان هدف درجهیک پشتیبانی میکنند.
راه سوم: هدفگیری WebAssembly
راه سوم به ما اجازه میدهد هم کیک را داشته باشیم و هم آن را بخوریم. SQLite از قبل یک بیلد WebAssembly (Wasm) ارائه میدهد، بنابراین باید بتوانیم آن را بگیریم و با استفاده از یک Wasm Runtime داخل برنامهمان اجرا کنیم. Wasm یک قالب بایتکد شبیه به بایتکد JVM است و همهجا اجرا میشود (از جمله بهصورت بومی در مرورگر). همچنین بهسرعت در حال تبدیلشدن به یک هدف کامپایل رایج برای بسیاری از زبانهاست. بسیاری از کامپایلرها (از جمله پروژه LLVM) آن را بهعنوان هدف درجهیک پذیرفتهاند، بنابراین فقط به C محدود نیست. و البته، در تمام مرورگرها و حتی در برخی کتابخانههای استاندارد زبانهای برنامهنویسی تعبیه شده است.
علاوه بر قابلیت حمل، Wasm مزایای امنیتی متعددی دارد که بسیاری از نگرانیهای ما درباره اجرای کد بومی در زمان اجرا را برطرف میکند. مدل حافظه Wasm از رایجترین حملات حافظه جلوگیری میکند. دسترسی به حافظه در یک حافظه خطی که میزبان مالک آن است sandbox میشود. این یعنی JVM ما میتواند در این فضای حافظه بخواند و بنویسد، اما کد Wasm بدون اینکه صراحتاً قابلیت لازم به آن داده شود، نمیتواند حافظه JVM را بخواند یا بنویسد. Wasm همچنین یکپارچگی جریان کنترل را در طراحی خود دارد. جریان کنترل در بایتکد رمزگذاری شده است و معناشناسی اجرا بهصورت ضمنی ایمنی را تضمین میکند.
Wasm همچنین یک مدل «منع بهصورت پیشفرض» برای قابلیتها دارد. بهصورت پیشفرض، یک برنامه Wasm فقط میتواند محاسبه کند و حافظه خودش را دستکاری کند. مثلاً هیچ دسترسیای به منابع سیستم از طریق system callها ندارد. با این حال، این قابلیتها میتوانند بهصورت جداگانه و تحت کنترل شما اعطا شوند. برای مثال، اگر از یک ماژول فشردهسازی بدون اتلاف استفاده میکنید، میتوانید با خیال راحت فرض کنید که هرگز به قابلیت کنترل یک socket نیاز نخواهد داشت. Wasm میتواند تضمین کند که کد فقط بایتها را در زمان اجرا پردازش میکند و هیچ کار دیگری انجام نمیدهد. اما اگر چیزی مثل SQLite اجرا میکنید، میتوانید دسترسی محدود به سیستم فایل بدهید و آن را فقط به دایرکتوریهای موردنیاز محدود کنید.
اجرای Wasm در JVM
خب، این Wasm Runtimeها را از کجا بیاوریم؟ امروزه گزینههای عالی زیادی وجود دارد. V8 یکی را بهصورت تعبیهشده دارد و بسیار هم سریع است. همچنین گزینههای مستقل زیادی مثل wasmtime، wasmer، wamr، wasmedge، wazero و غیره وجود دارد.
اما چطور اینها را در JVM اجرا کنیم؟ آنها به C، C++، Rust، Go و غیره نوشته شدهاند. خب، فقط باید دوباره به لینکسازی پویا برگردیم!
شوخی aside، این هنوز هم میتواند یک گزینه قدرتمند باشد. اما ما یک راهحل بهتر برای JVM میخواستیم، بنابراین Chicory را ساختیم؛ یک Wasm runtime کاملاً مبتنی بر JVM با صفر وابستگی بومی. تنها کاری که باید بکنید این است که jar را به پروژهتان اضافه کنید و میتوانید کدی که برای Wasm کامپایل شده را اجرا کنید.
LibSqlite در Chicory
بیایید Chicory را در عمل ببینیم. برای پایبندی به مثال SQLite، تصمیم گرفتم چند binding جدید برای یک بیلد Wasm از libsqlite بسازم.
شما هرگز نباید برای بهرهبردن از این تکنیک مجبور باشید جزئیات سطح پایین را بفهمید، اما اگر علاقهمند به ساخت bindingهای بدون وابستگی خودتان هستید، مراحل اصلی را توضیح میدهم. نمونهکدها صرفاً برای نمایش هستند و برخی جزئیات و مدیریت حافظه کنار گذاشته شدهاند. برای تصویر کاملتر میتوانید مخزن GitHub ذکرشده در بالا را بررسی کنید.
ابتدا باید SQLite را به Wasm کامپایل کنیم و توابع مناسب را برای فراخوانی صادر کنیم. ما یک برنامه wrapper کوچک به C نوشتهایم تا مثال سادهتر شود، اما باید بتوانیم این کار را با کامپایل مستقیم SQLite هم انجام دهیم.
برای کامپایل کد C، از wasi-sdk استفاده میکنیم. این نسخه اصلاحشده clang میتواند با هدف Wasi 0.1 کامپایل شود. این کار یک رابط سیستمی به Wasm ساده اضافه میکند که شباهت نزدیکی به POSIX دارد. این کار لازم است چون کد SQLite ما باید با سیستم فایل تعامل داشته باشد و Wasm ذاتاً شناختی از سیستم زیرین ندارد. Chicory از Wasi پشتیبانی میکند تا بتوانیم این را اجرا کنیم.
ما این را در Makefile کامپایل میکنیم و حداقل توابع لازم برای کارکرد را صادر میکنیم:
-
ما
reallocرا صادر میکنیم-
این اجازه میدهد داخل ماژول SQLite حافظه تخصیص و آزاد کنیم
-
همچنان باید حافظه را بهصورت دستی تخصیص و آزاد کنیم و از همان allocator که کد SQLite استفاده میکند بهره ببریم
-
برای ارسال داده به SQLite و پاکسازی پس از آن به این نیاز داریم
-
-
ما تابع
sqlite_callbackرا وارد میکنیم-
Chicory اجازه میدهد ارجاع به توابع Java را از طریق «imports» به کد کامپایلشده پاس بدهید
-
پیادهسازی این callback را در Java مینویسیم
-
این callback برای گرفتن نتایج تابع
sqlite3_execلازم است
-
حالا میتوانیم به کد Java نگاه کنیم. ابتدا باید ماژول را بارگذاری و نمونهسازی کنیم. اما قبل از نمونهسازی، باید imports را برآورده کنیم. این ماژول به imports مربوط به Wasi و تابع سفارشی sqlite_callback نیاز دارد. Chicory imports مربوط به Wasi را فراهم میکند؛ برای callback باید یک HostFunction بسازیم:
حالا که imports را داریم، میتوانیم ماژول Wasm را بارگذاری و نمونهسازی کنیم:
با این هندلها، حالا میتوانیم شروع به فراخوانی کد C کنیم. برای مثال، برای بازکردن پایگاه داده (متدهای کمکی برای اختصار حذف شدهاند):
برای اجرا، فقط یک رشته برای SQL تخصیص میدهیم و اشارهگر آن و پایگاه داده را پاس میدهیم:
کنار هم گذاشتن همهچیز
بعد از اینکه همهچیز را در چند لایه انتزاع پیچیدیم، میتوانیم به یک رابط ساده مثل این برسیم. اینجا نمونهای از یک query روی پایگاه داده Chinook آمده است:
اضافهکردن یک آسیبپذیری برای سرگرمی
چند آسیبپذیری به افزونه اضافه کردم تا ببینم چه اتفاقی میافتد.
اول، یک payload شِل معکوس ساختم و سعی کردم با کد آن را فعال کنم. خوشبختانه، این حتی کامپایل هم نشد، چون Wasi Preview 1 قابلیت دستکاری socketهای سطح پایین را پشتیبانی نمیکند. بنابراین مطمئن هستیم که حتی اگر کامپایل هم میشد، این توابع در زمان اجرا در دسترس نبودند.
بعد سراغ چیزی سادهتر رفتم: این کد /etc/passwd را کپی میکند و سعی میکند آن را چاپ کند. همچنین یک خط اضافه کردم که اگر SQL شامل عبارت opensesame بود، این backdoor فعال شود:
تغییر query واقعاً backdoor را فعال میکند:
با این حال، Chicory با خطای result = ENOENT پاسخ داد، چون فایل /etc/passwd برای guest قابل مشاهده نیست. دلیلش این است که ما فقط پوشه حاوی پایگاه داده SQLite را map کردهایم و هیچ آگاهی دیگری از سیستم فایل میزبان ندارد.
احتمال اینکه یک آسیبپذیری backdoor واقعاً وارد SQLite شود بسیار کم است. این یک کدبیس جمعوجور و بهخوبی شناختهشده با چشمهای زیاد است. اما همین را نمیتوان درباره همه افزونهها و استقرارها گفت. بسیاری از افزونهها سطح حمله بزرگی از نظر وابستگیها دارند. حملات زنجیره تأمین ممکن است رخ دهند. و اگر به کاربران خود متکی باشید که افزونه بومی را بیاورند، چطور میتوانید مطمئن شوید که فاقد آسیبپذیری، مخرب یا غیرمخرب، است؟ برای آنها، این فقط یک باینری دیگر روی ماشینشان است که باید به آن اعتماد کنند.
جمعبندی
Chicory به شما اجازه میدهد کد یک زبان برنامهنویسی دیگر را بهصورت ایمن در برنامه Java خود اجرا کنید. علاوه بر این، قابلیت حمل و تضمینهای sandboxing آن، این ابزار را به گزینهای عالی برای ساخت سیستمهای پلاگین امن تبدیل میکند تا برنامه Java شما توسط توسعهدهندگان شخص ثالث قابل گسترش شود.
با اینکه Chicory هنوز در حال توسعه است، کاربران آن را در پروژههای مختلفی استفاده میکنند؛ از سیستمهای پلاگین در Apache Camel و Kafka Connect گرفته تا تجزیه کد منبع Ruby در JRuby، اجرای یک مدل llama، و حتی DOOM. ما یک جامعه توزیعشده جهانی هستیم و نگهدارندگانی از چند سازمان بزرگ داریم که توسعه را پیش میبرند.
در این مرحله، مفسر پیادهسازیشده با Wasi 0.1 از نظر مشخصات کامل است؛ تمام ۲۸,۰۰۰ تست TCK با موفقیت پاس شدهاند. در گام بعدی، مشارکتکنندگان روی تکمیل منطق اعتبارسنجی برای تکمیل مشخصات، نهاییسازی API نسخه ۱.۰، و تکمیل کامپایلر بایتکد Wasm→JVM برای بهبود کارایی تمرکز خواهند کرد.
بازخورد و مشارکتها بسیار ارزشمند هستند، چون پروژه هنوز در مراحل اولیه است، بهویژه در سادهسازی توسعه bindingها. ما فکر میکنیم آسانترکردن تعامل با C، بهخصوص اگر بتوانیم از رابطهای موجود برای FFI استفاده کنیم، مهاجرت افزونههای بومی به Wasm را برای افراد بسیار ساده خواهد کرد.







