چگونه Go 1.28 محدود کردن دسترسی پردازه‌های فرزند را در لینوکس ساده‌تر می‌کند.

توجه: این مقاله بر اساس نسخهٔ توسعه‌ای Go 1.28 نوشته شده است که انتظار می‌رود در فوریهٔ ۲۰۲۷ منتشر شود. ممکن است API آن تا انتشار نسخهٔ نهایی تغییر کند.

اجرای فرمان‌های خارجی ساده است؛ اما امنیت آن‌ها چه می‌شود؟

یکی از ویژگی‌هایی که در Go دوست دارم، سادگی کار با برنامه‌های خارجی است. با استفاده از os/exec می‌توانیم یک پردازهٔ جدید اجرا کنیم، آرگومان‌های موردنیاز را به آن بدهیم و خروجی‌اش را تنها با چند خط کد دریافت کنیم.

cmd := exec.Command("some-tool", "--process", "input.txt")
output, err := cmd.CombinedOutput()
if err != nil {
    log.Fatal(err)
}
fmt.Println(string(output))

اما نکته‌ای امنیتی وجود دارد که باید به آن توجه کنیم. برنامه‌های خارجی معمولاً با همان سطح دسترسی کاربری اجرا می‌شوند که برنامهٔ ما دارد. بنابراین، اگر برنامهٔ اصلی بتواند فایل‌های حساس را بخواند یا دایرکتوری‌های مهم را تغییر دهد، پردازه‌های فرزند (subprocesses) نیز ممکن است همین دسترسی‌ها را داشته باشند. وقتی یک ابزار توسعه می‌سازم، گاهی لازم است فرمان‌های خارجی را اجرا کنم، بدون اینکه بخواهم تمام دسترسی‌های برنامهٔ اصلی را در اختیار آن‌ها بگذارم. اینجاست که مفهوم sandboxing اهمیت پیدا می‌کند. لینوکس روش‌های مختلفی برای جداسازی پردازه‌ها دارد؛ ازجمله namespaces و seccomp. کانتینرها نیز با ترکیب چند سازوکار، محیطی جداشده برای اجرای برنامه‌ها فراهم می‌کنند. یکی دیگر از قابلیت‌های جالب لینوکس Landlock است که استفاده از آن برای پردازه‌های فرزند در Go 1.28 ساده‌تر شده است.

Landlock چیست؟

Landlock یک قابلیت امنیتی در لینوکس است که از نسخهٔ 5.13 کرنل و به‌عنوان بخشی از چارچوب امنیتی Linux Security Modules (LSM) معرفی شد. این قابلیت مخصوص لینوکس است و در macOS یا Windows وجود ندارد. ویژگی جالب Landlock این است که برنامه‌ها می‌توانند بدون دسترسی root، محدودیت‌های امنیتی جدیدی برای خود تعریف کنند. برای مثال، یک پردازه می‌تواند دسترسی‌اش به فایل‌ها و دایرکتوری‌ها را محدود کند. نسخه‌های جدیدتر کرنل از برخی محدودیت‌های شبکه نیز پشتیبانی می‌کنند. این محدودیت‌ها در قالب یک ruleset تعریف می‌شوند. Ruleset مشخص می‌کند که Landlock کدام عملیات را کنترل کند و انجام آن‌ها در چه بخش‌هایی مجاز باشد. Landlock از مدل allowlist استفاده می‌کند؛ یعنی اگر عملیاتی تحت کنترل ruleset باشد، انجام آن تنها زمانی مجاز است که صریحاً اجازه داده شده باشد. نکتهٔ مهم دیگر این است که Landlock فقط می‌تواند دسترسی‌های موجود را محدود کند و نمی‌تواند دسترسی جدیدی به پردازه بدهد. همچنین، محدودیت‌های اعمال‌شده به پردازه‌های فرزند آینده نیز منتقل می‌شوند.

چه چیزی در Go 1.28 تغییر کرده است؟

Landlock برای توسعه‌دهندگان Go قابلیت جدیدی نیست. کتابخانه‌هایی مانند go-landlock و ابزارهایی مانند Landrun از قبل امکان استفاده از آن را فراهم کرده بودند. چالش اصلی زمانی بود که می‌خواستیم فقط یک پردازهٔ فرزند را محدود کنیم، بدون اینکه سطح دسترسی برنامهٔ اصلی تغییر کند. Landlock معمولاً محدودیت‌ها را روی thread فراخوان و پردازه‌های فرزند آیندهٔ آن اعمال می‌کند. ازآنجاکه Go چندین thread را مدیریت می‌کند، اعمال مستقیم این محدودیت‌ها نیازمند دقت است. یکی از راه‌حل‌های رایج، استفاده از یک برنامهٔ واسط بود که ابتدا سیاست امنیتی را اعمال می‌کرد و سپس برنامهٔ مقصد را اجرا می‌کرد. در Go 1.28، این کار با اضافه شدن پشتیبانی Landlock به syscall.SysProcAttr ساده‌تر شده است. چهار فیلد مرتبط با این قابلیت عبارت‌اند از:

فیلدکاربرد
NoNewPrivsجلوگیری از افزایش سطح دسترسی هنگام اجرای برنامه
UseLandlockفعال کردن اعمال سیاست Landlock روی پردازهٔ فرزند
LandlockFDمشخص کردن فایل‌دیسکریپتور ruleset
LandlockFlagsتعیین گزینه‌های اضافی هنگام اعمال سیاست

Go پیش از اجرای برنامهٔ مقصد، ruleset را روی پردازهٔ فرزند اعمال می‌کند و سطح دسترسی پردازهٔ والد بدون تغییر باقی می‌ماند. در مثال این مقاله از os/exec استفاده می‌کنیم، اما همین قابلیت از طریق os.StartProcess نیز در دسترس است. البته یک تفاوت مهم وجود دارد: Go 1.28 امکان اعمال ruleset را اضافه کرده است، نه ساخت آن را. همچنان باید سیاست امنیتی را با استفاده از رابط Landlock در لینوکس یا یک کتابخانهٔ مناسب تعریف کنیم.

اجرای یک پردازهٔ فرزند با دسترسی محدود

فرض کنیم در حال ساخت برنامه‌ای هستیم که پردازه‌های فرزند را اجرا می‌کند. می‌خواهیم این پردازه‌ها به‌صورت عادی کار کنند، اما اجازهٔ ساخت دایرکتوری جدید را نداشته باشند. با Landlock می‌توانیم چنین محدودیتی تعریف کنیم و هنگام اجرای پردازهٔ فرزند، آن را اعمال کنیم. برای ساخت ruleset از پکیج golang.org/x/sys/unix استفاده می‌کنیم. پکیج unix بخشی از ماژول golang.org/x/sys است که زیر نظر پروژهٔ Go توسعه می‌یابد، اما جزو کتابخانهٔ استاندارد Go نیست. مزیت آن این است که ثابت‌های لینوکس و شمارهٔ syscallهای متناسب با معماری‌های مختلف را در اختیار ما قرار می‌دهد. نمونه‌کد کامل به این شکل است:

//go:build linux && go1.28

package main

import (
    "errors"
    "fmt"
    "log"
    "os"
    "os/exec"
    "path/filepath"
    "syscall"
    "unsafe"

    "golang.org/x/sys/unix"
)

func createRuleset() (int, error) {
    attr := struct {
        HandledAccessFS uint64
    }{
        HandledAccessFS: unix.LANDLOCK_ACCESS_FS_MAKE_DIR,
    }
    fd, _, errno := unix.Syscall(
        unix.SYS_LANDLOCK_CREATE_RULESET,
        uintptr(unsafe.Pointer(&attr)),
        unsafe.Sizeof(attr),
        0,
    )
    if errno != 0 {
        return -1, errno
    }
    return int(fd), nil
}

func main() {
    dir, err := os.MkdirTemp("", "landlock-demo-")
    if err != nil {
        log.Fatal(err)
    }
    defer os.RemoveAll(dir)

    rulesetFD, err := createRuleset()
    if err != nil {
        log.Fatal(err)
    }
    defer unix.Close(rulesetFD)

    cmd := exec.Command(
        "mkdir",
        filepath.Join(dir, "from-child"),
    )
    cmd.SysProcAttr = &syscall.SysProcAttr{
        NoNewPrivs:    true,
        UseLandlock:   true,
        LandlockFD:    rulesetFD,
        LandlockFlags: 0,
    }

    output, err := cmd.CombinedOutput()
    if err == nil {
        log.Fatal("Child unexpectedly created a directory")
    }
    var exitErr *exec.ExitError
    if !errors.As(err, &exitErr) {
        log.Fatalf("Could not start child: %v", err)
    }
    fmt.Printf("Child: %s", output)

    err = os.Mkdir(
        filepath.Join(dir, "from-parent"),
        0o700,
    )
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Parent: directory created successfully")
}

کد چگونه کار می‌کند؟

تابع createRuleset یک ruleset می‌سازد که عملیات ایجاد دایرکتوری را با استفاده از LANDLOCK_ACCESS_FS_MAKE_DIR کنترل می‌کند. ازآنجاکه هیچ قانونی برای مجاز کردن این عملیات اضافه نکرده‌ایم، ساخت دایرکتوری ممنوع می‌شود. سایر عملیات فایل‌سیستم تحت این محدودیت مشخص قرار نمی‌گیرند. بخش اصلی مثال، تنظیم SysProcAttr است:

cmd.SysProcAttr = &syscall.SysProcAttr{
    NoNewPrivs:    true,
    UseLandlock:   true,
    LandlockFD:    rulesetFD,
    LandlockFlags: 0,
}

فیلد NoNewPrivs از افزایش سطح دسترسی هنگام اجرای برنامه جلوگیری می‌کند و پیش‌نیاز معمول اعمال Landlock بدون دسترسی ویژه را فراهم می‌سازد. با UseLandlock نیز از Go می‌خواهیم ruleset مشخص‌شده در LandlockFD را روی پردازهٔ فرزند اعمال کند. وقتی فرمان اجرا می‌شود، Go پیش از اجرای mkdir محدودیت را اعمال می‌کند. در یک سیستم سازگار، انتظار داریم خروجی مشابه زیر باشد:

Child: mkdir: cannot create directory '/tmp/landlock-demo-87340035/from-child': Permission denied
Parent: directory created successfully

پردازهٔ فرزند اجازهٔ ایجاد دایرکتوری ندارد، اما برنامهٔ اصلی همچنان می‌تواند در همان مسیر دایرکتوری دیگری بسازد. این دقیقاً همان رفتاری است که می‌خواستیم؛ بدون نیاز به یک برنامهٔ واسط و بدون محدود کردن پردازهٔ والد.

اجرای مثال

برای اجرای کد، به لینوکسی با Landlock فعال، نسخهٔ توسعه‌ای Go 1.28 و فرمان mkdir نیاز داریم. کد را در فایلی با نام main.go ذخیره می‌کنیم و دستورهای زیر را اجرا می‌کنیم:

go mod init example.com/landlock-demo
go get golang.org/x/sys/unix
go run .

اگر Landlock پشتیبانی نشود یا غیرفعال باشد، ساخت ruleset با خطا مواجه می‌شود و برنامه بدون اعمال محدودیت به اجرای پردازهٔ فرزند ادامه نمی‌دهد. نسخهٔ قابل‌اجرای این مثال نیز در GitHub در دسترس است.

چرا این تغییر اهمیت دارد؟

برای من، مهم‌ترین مزیت این قابلیت، هماهنگی آن با روش معمول اجرای پردازه‌ها در Go است. اگر از قبل برای اجرای برنامه‌های خارجی از os/exec استفاده می‌کنم، حالا می‌توانم بدون اضافه کردن یک برنامهٔ واسط، محدودیت‌های Landlock را نیز تنظیم کنم. این قابلیت برای ابزارهای build، سیستم‌های اتوماسیون و برنامه‌هایی که فرمان‌های خارجی اجرا می‌کنند کاربرد دارد. مدیریت خطا نیز ساده‌تر شده است. اگر Go نتواند سیاست Landlock را اعمال کند، ایجاد پردازهٔ فرزند با خطا مواجه می‌شود و برنامهٔ مقصد بدون محدودیت‌های درخواستی اجرا نخواهد شد. البته یک launcher که درست پیاده‌سازی شده باشد، می‌تواند همان تضمین‌های امنیتی را فراهم کند. Go 1.28 امنیت خود Landlock را افزایش نمی‌دهد، بلکه کدی را که برای مدیریت اجرای پردازه‌ها نیاز داریم کاهش می‌دهد.

چند نکته که باید در نظر بگیریم

Landlock حتی با وجود پشتیبانی جدید Go محدودیت‌هایی دارد. سازگاری کرنل مهم است. این قابلیت باید در لینوکس فعال باشد و محدودیت‌های قابل‌استفاده نیز به نسخهٔ Landlock ABI بستگی دارند. تعریف سیاست امنیتی همچنان بر عهدهٔ ماست. مثال ما فقط ساخت دایرکتوری را محدود می‌کند و مانع خواندن فایل‌های حساس یا اجرای سایر عملیات مجاز نمی‌شود. Landlock یک sandbox کامل نیست. بعضی عملیات خارج از محدودهٔ پشتیبانی آن هستند و فایل‌دیسکریپتورهای به‌ارث‌رسیده نیز ممکن است همچنان دسترسی‌هایی فراهم کنند. بسته به نیاز امنیتی برنامه، شاید لازم باشد از سازوکارهای دیگری هم استفاده کنیم. درنهایت، اگر اجرای امن پردازه‌ها برای برنامه ضروری است، نباید خطاهای اعمال سیاست امنیتی را نادیده بگیریم.

سخن پایانی

چیزی که در این تغییر دوست دارم این است که Go استفاده از یک قابلیت امنیتی موجود در لینوکس را ساده‌تر کرده، بدون اینکه انتزاع کاملاً جدیدی معرفی کند. هنوز باید ruleset مناسب را بسازیم و محدودیت‌های Landlock را بشناسیم، اما اعمال این سیاست روی پردازه‌های فرزند اکنون بخشی از مسیر معمول اجرای آن‌ها در Go شده است. به نظرم این تغییر برای توسعه‌دهندگانی که با Go ابزارهایی برای اجرای فرمان‌های خارجی روی لینوکس می‌سازند، بهبود کاربردی و مفیدی است.

منابع