توی این درس یاد میگیری کانتینرها را طوری اجرا کنی که اگر کسی به یکیشان نفوذ کرد، کار زیادی از او برنیاید. چهار اصل پایه را عملی میبینی: اجرا با کاربر غیر root (USER در Dockerfile)، نگه داشتن رازها بیرون از image (ENV و ARG لو میروند؛ --mount=type=secret نه)، فایلسیستم فقطخواندنی با docker run --read-only، و حذف دسترسیهای اضافه با --cap-drop ALL. و میفهمی چرا --privileged و mount کردن docker.sock خطرناکاند. در تمرین اصلی یک کانتینر را با کمترین دسترسی اجرا میکنی.
مسئله: کانتینر یک جعبهی امن مطلق نیست
Section titled “مسئله: کانتینر یک جعبهی امن مطلق نیست”کانتینر فقط یک پروسهی جداشده روی هستهی میزبان است (درس «کانتینر در برابر VM»). اگر مهاجم از طریق یک باگ در اپ تو داخل کانتینر کد اجرا کند، هر چه کانتینر اجازه دارد برای او هم مجاز است: اگر root باشد، فایلسیستم قابلنوشتن باشد و دسترسیهای اضافی داشته باشد، دامنهی خرابکاری بزرگ است. کار ما کوچک کردن این دامنه است، قبل از اینکه چیزی اتفاق بیفتد.
تشبیه: کارمند با کارت دسترسی
Section titled “تشبیه: کارمند با کارت دسترسی”یک کارمند را با کارت دسترسی به ساختمان میفرستی. کارت مدیر (root) همهی درها را باز میکند. بهتر است کارت هر کارمند فقط درهای لازمش را باز کند (غیر root)، گاوصندوق را قفلشده ببیند (read-only)، و کلیدهای ویژهی نگهبانی را نداشته باشد (capabilities). و رمز گاوصندوق را روی کارتش ننویس (راز در image نباشد).
مثالهای عملی
Section titled “مثالهای عملی”مثال ۱: بهطور پیشفرض root هستی
Section titled “مثال ۱: بهطور پیشفرض root هستی”docker run --rm alpine iddocker run --rm --user 1000:1000 alpine iddocker run --rm --user nobody alpine iduid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)uid=1000 gid=1000 groups=1000uid=65534(nobody) gid=65534(nobody) groups=65534(nobody)پیشفرض کانتینر uid=0 (root) است. با --user میتوانی موقع اجرا کاربر دیگری بدهی؛ بهتر است این را در خود Dockerfile ثابت کنی.
اگر Dockerfile دستور USER نداشته باشد، پروسهی کانتینر با کدام کاربر اجرا میشود؟
بدون USER، پیشفرض root (uid 0) است.
مثال ۲: USER در Dockerfile
Section titled “مثال ۲: USER در Dockerfile”FROM python:3.12-alpineRUN adduser -D -u 10001 appWORKDIR /appRUN chown app:app /appCOPY --chown=app:app main.py .USER appCMD ["python", "main.py"]import os
print("uid =", os.getuid())try: open("/etc/blocked", "w").write("x")except OSError as e: print("نوشتن در /etc:", e.strerror)open("/app/ok.txt", "w").write("x")print("نوشتن در /app/ok.txt: موفق")docker build -q -t lx-sec-user sec/user >/dev/nulldocker run --rm lx-sec-useruid = 10001نوشتن در /etc: Permission deniedنوشتن در /app/ok.txt: موفقاپ با uid=10001 اجرا شد: نمیتواند در /etc بنویسد (Permission denied) ولی در پوشهی خودش میتواند. COPY --chown فایل را به همان کاربر میدهد تا مجبور نباشی chmod 777 بزنی. image های رسمی بعضی زبانها یک کاربر آماده دارند و فقط کافی است USER node بنویسی:
docker run --rm node:22-alpine sh -c 'id node'uid=1000(node) gid=1000(node) groups=1000(node),1000(node)نکته: uid عددی (مثل 10001) از اسم بهتر است، چون در بعضی ابزارها (مثل Kubernetes) اسم را نمیتوان بررسی کرد.
مثال ۳: رازها داخل image لو میروند (ENV و ARG)
Section titled “مثال ۳: رازها داخل image لو میروند (ENV و ARG)”FROM alpineARG API_TOKENENV DB_PASSWORD=s3cr3t-in-envRUN echo "token length: ${#API_TOKEN}"CMD ["sh"]docker build -q -t lx-sec-bad --build-arg API_TOKEN=tok-in-arg sec/leak >/dev/nullecho "--- docker inspect (Env):"docker image inspect lx-sec-bad --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -i passwordecho "--- docker history:"docker history --no-trunc lx-sec-bad --format '{{.CreatedBy}}' | grep -oE '(DB_PASSWORD|API_TOKEN)=[^ ]+' | sort -u--- docker inspect (Env):DB_PASSWORD=s3cr3t-in-env--- docker history:API_TOKEN=tok-in-argDB_PASSWORD=s3cr3t-in-envهر دو راز در متادیتای image هستند و هر کس image را بگیرد، بدون هیچ دسترسی خاصی میخواندشان. حتی اگر بعداً ENV را پاک کنی، لایههای قبلی و تاریخچه میمانند. قاعده: رمز، توکن و کلید را هیچوقت با ENV یا ARG وارد image نکن.
مثال ۴: راز به روش درست: BuildKit secret
Section titled “مثال ۴: راز به روش درست: BuildKit secret”برای رازی که فقط هنگام build لازم است (مثلاً توکن دانلود از یک مخزن خصوصی)، RUN --mount=type=secret فایل راز را فقط در همان دستور و بهصورت موقت mount میکند و در هیچ لایهای نمیماند:
FROM alpineRUN --mount=type=secret,id=token \ echo "راز خوانده شد، طول: $(wc -c < /run/secrets/token)"CMD ["sh", "-c", "ls /run/secrets 2>&1"]cd sec/secretprintf 'sup3r-secret-token' > token.txtdocker build --progress=plain --no-cache --secret id=token,src=token.txt -t lx-sec-ok . 2>&1 | grep -E 'راز خوانده' | sed -E 's/^#[0-9]+ [0-9.]+ //'echo "--- جستوجوی راز در همهی لایههای image:"docker save -o ../ok.tar lx-sec-okmkdir -p ../ok && tar -xf ../ok.tar -C ../okgrep -rl --binary-files=text 'sup3r-secret-token' ../ok | wc -l | sed 's/^/تعداد فایلهایی که راز را دارند: /'echo "--- داخل کانتینر:"docker run --rm lx-sec-ok#5 [stage-0 2/2] RUN --mount=type=secret,id=token echo "راز خوانده شد، طول: $(wc -c < /run/secrets/token)"راز خوانده شد، طول: 18--- جستوجوی راز در همهی لایههای image:تعداد فایلهایی که راز را دارند: 0--- داخل کانتینر:ls: /run/secrets: No such file or directoryراز در build خوانده شد ولی در هیچ فایلی از image پیدا نشد، و در کانتینر هم /run/secrets خالی است. فایل token.txt هم نباید commit شود. برای رازی که در زمان اجرا لازم است (پسورد دیتابیس)، آن را در زمان docker run بده: -e یا بهتر، --env-file، یا secret در Compose/Swarm؛ در هر حال نه داخل image.
مثال ۵: فایلسیستم فقطخواندنی
Section titled “مثال ۵: فایلسیستم فقطخواندنی”docker run -d --name lx-sec1 --read-only --tmpfs /tmp python:3.12-alpine sleep 60 >/dev/nullecho "نوشتن در /etc: $(docker exec lx-sec1 sh -c 'echo x > /etc/hack' 2>&1)"echo "نوشتن در /tmp: $(docker exec lx-sec1 sh -c 'echo x > /tmp/ok && echo موفق' 2>&1)"docker inspect lx-sec1 --format 'ReadonlyRootfs = {{.HostConfig.ReadonlyRootfs}}'docker rm -f lx-sec1 >/dev/nullنوشتن در /etc: sh: can't create /etc/hack: Read-only file systemنوشتن در /tmp: موفقReadonlyRootfs = trueبا --read-only کل فایلسیستم ریشه فقطخواندنی است. مهاجم نمیتواند فایل اپ را عوض کند یا ابزار دانلود کند. اپ اگر باید جایی بنویسد (فایل موقت، pid، کش)، همان مسیرها را با --tmpfs (حافظه) یا یک volume مشخص قابلنوشتن کن. بیشتر اپها با --tmpfs /tmp کار میکنند.
مثال ۶: capabilities، قدرتهای ویژهی root
Section titled “مثال ۶: capabilities، قدرتهای ویژهی root”root لینوکس به capabilityهای جدا (مثل CHOWN, NET_RAW, SYS_ADMIN) تقسیم شده. داکر بهطور پیشفرض چند تا را نگه میدارد. ببین چه باقی مانده و چه چیزی با حذفشان از کار میافتد:
echo "پیشفرض: $(docker run --rm alpine sh -c 'grep CapEff /proc/self/status' | awk '{print $2}')"echo "--cap-drop ALL: $(docker run --rm --cap-drop ALL alpine sh -c 'grep CapEff /proc/self/status' | awk '{print $2}')"echo "--privileged: $(docker run --rm --privileged alpine sh -c 'grep CapEff /proc/self/status' | awk '{print $2}')"echo "--- chown در حالت پیشفرض و بدون capability:"docker run --rm alpine sh -c 'chown 1000 /tmp && echo "chown موفق"'docker run --rm --cap-drop ALL alpine sh -c 'chown 1000 /tmp' 2>&1echo "--- اضافه کردن فقط CHOWN:"docker run --rm --cap-drop ALL --cap-add CHOWN alpine sh -c 'chown 1000 /tmp && echo "chown موفق"'پیشفرض: 00000000a80425fb--cap-drop ALL: 0000000000000000--privileged: 000001ffffffffff--- chown در حالت پیشفرض و بدون capability:chown موفقchown: /tmp: Operation not permitted--- اضافه کردن فقط CHOWN:chown موفقCapEff مجموعهی capability های فعال است (عدد هگز). پیشفرض داکر چند capability دارد؛ با --cap-drop ALL صفر میشود و chown میشکند (Operation not permitted)؛ با --cap-add CHOWN فقط همان را برمیگردانیم. اصل least privilege: همه را بردار، فقط آنچه اپ واقعاً لازم دارد برگردان. (--privileged تقریباً همهی قدرتها را برمیگرداند.)
مثال ۷: --privileged و docker.sock خطرناکاند
Section titled “مثال ۷: --privileged و docker.sock خطرناکاند”echo "دستگاههای قابلدیدن: عادی=$(docker run --rm alpine ls /dev | wc -l | tr -d ' ') privileged=$(docker run --rm --privileged alpine ls /dev | wc -l | tr -d ' ')"دستگاههای قابلدیدن: عادی=15 privileged=184با --privileged کانتینر به دستگاههای میزبان دسترسی دارد و تقریباً جداسازی از بین میرود. و mount کردن سوکت داکر یعنی کنترل کامل داکر میزبان:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock alpine sh -c 'apk add --no-cache curl >/dev/null 2>&1; curl -s --unix-socket /var/run/docker.sock http://localhost/version | grep -o "\"Version\":\"[^\"]*\"" | head -1'"Version":"29.8.1"کانتینری که فقط یک سوکت داشت، نسخهی داکر میزبان را از API پرسید؛ با همین API میتواند کانتینر جدید با mount کل دیسک بسازد. قاعده: docker.sock را فقط به ابزار قابلاعتماد بده، آن هم کم و آگاهانه؛ و --privileged را مگر برای کار خیلی خاص (مثل docker-in-docker آزمایشی) اجرا نکن.
مثال ۸: همه با هم: اجرای حداقلی
Section titled “مثال ۸: همه با هم: اجرای حداقلی”docker run -d --name lx-sec2 \ --user 10001:10001 \ --read-only --tmpfs /tmp \ --cap-drop ALL \ --security-opt no-new-privileges \ --pids-limit 100 --memory 128m \ python:3.12-alpine python -m http.server 8000 >/dev/nullsleep 2docker inspect lx-sec2 --format 'user={{.Config.User}} readonly={{.HostConfig.ReadonlyRootfs}} capdrop={{.HostConfig.CapDrop}} secopt={{.HostConfig.SecurityOpt}} pids={{.HostConfig.PidsLimit}}'docker exec lx-sec2 python -c "import urllib.request; print('پاسخ سرور:', urllib.request.urlopen('http://localhost:8000').status)"docker rm -f lx-sec2 >/dev/nulluser=10001:10001 readonly=true capdrop=[ALL] secopt=[no-new-privileges] pids=100پاسخ سرور: 200اپ با کاربر عادی، فایلسیستم فقطخواندنی، بدون هیچ capability، با no-new-privileges (جلوگیری از بالا بردن دسترسی با باینریهای setuid) و حد روی تعداد پروسه و حافظه اجرا شد و کار میکند. در Compose معادلها: user:, read_only: true, tmpfs:, cap_drop: [ALL], security_opt:, pids_limit:, mem_limit:.
پشت پرده
Section titled “پشت پرده”امنیت کانتینر روی چند قابلیت هستهی لینوکس ساخته شده است: namespaceها (دیدن جدا)، cgroupها (محدودیت منابع)، capabilities (شکستن قدرت root به تکهها) و فیلترهای seccomp و AppArmor/SELinux (محدود کردن فراخوانیهای سیستمی). داکر بهطور پیشفرض یک پروفایل seccomp و مجموعهی کوچکشدهای از capability ها اعمال میکند، به همین دلیل مثال ۶ در حالت پیشفرض عدد کوچکتری از --privileged داشت.
یک ظرافت مهم: root داخل کانتینر همان uid ۰ روی میزبان است (مگر user namespace یا rootless فعال باشد). یعنی اگر کانتینر بهخاطر باگ یا تنظیم بد (--privileged، mount حساس) از جداسازی بیرون بیاید، با اختیار root میزبان بیرون میآید. برای همین هر لایهی دفاعی جدا ارزش دارد. حالت rootless (daemon داکر هم با کاربر عادی اجرا میشود) این ریسک را کم میکند؛ نصب و تست آن به ماشین لینوکس جدا نیاز دارد و اینجا اجرا نشده (فقط معرفی).
دو نکته برای رازها:
- متغیر محیطی در
docker inspectو/proc/<pid>/environقابل خواندن است؛ برای راز حساس، فایل mountشده (/run/secrets/…) بهتر است. - لایهها تغییرناپذیرند؛ رازی که یک بار وارد image شد، حتی با پاک کردن در لایهی بعد هم میماند. اگر لو رفت، رمز را عوض کن (حذف image کافی نیست).
جدولهای مرجع
Section titled “جدولهای مرجع”| میخواهم… | گزینه | معنی |
|---|---|---|
| اجرا با کاربر عادی | USER app / --user 10001 |
بدون root |
| فایلسیستم قفل | --read-only + --tmpfs /tmp |
مسیرهای لازم قابلنوشتن |
| حذف قدرتهای root | --cap-drop ALL (+ --cap-add X) |
least privilege |
| جلوگیری از بالا بردن دسترسی | --security-opt no-new-privileges |
setuid بیاثر |
| محدودیت پروسه/حافظه | --pids-limit N / --memory 128m |
مقابله با fork bomb |
| راز زمان build | RUN --mount=type=secret,id=X + --secret |
در image نمیماند |
| راز زمان اجرا | --env-file / secret در Compose |
نه داخل image |
| ممنوع/پرخطر | چرا |
|---|---|
--privileged |
تقریباً جداسازی را از بین میبرد |
mount /var/run/docker.sock |
کنترل کامل daemon میزبان |
ENV PASSWORD=... / ARG TOKEN |
در متادیتای image و تاریخچه |
latest + root + همهی capabilityها |
بدترین ترکیب |
اشتباهات رایج
Section titled “اشتباهات رایج”۱) فراموش کردن مسیر قابلنوشتن با --read-only
Section titled “۱) فراموش کردن مسیر قابلنوشتن با --read-only”docker run --rm --read-only python:3.12-alpine python -c "open('/app.log','w').write('x')" 2>&1 | tail -1OSError: [Errno 30] Read-only file system: '/app.log'اپ میخواهد فایل بنویسد و با Read-only file system میمیرد. راهحل: مسیر لازم را با --tmpfs یا volume قابلنوشتن کن.
۲) COPY بدون --chown و اجرا با کاربر غیر root
Section titled “۲) COPY بدون --chown و اجرا با کاربر غیر root”mkdir -p m2 && printf 'FROM python:3.12-alpine\nRUN adduser -D -u 10001 app\nWORKDIR /w\nCOPY . .\nUSER app\nCMD ["python","-c","open(\\"/w/out.txt\\",\\"w\\").write(\\"x\\")"]\n' > m2/Dockerfiledocker build -q -t lx-sec-m2 m2 >/dev/null && docker run --rm lx-sec-m2 2>&1 | tail -1docker rmi lx-sec-m2 >/dev/nullPermissionError: [Errno 13] Permission denied: '/w/out.txt'پوشهی /w مال root است و کاربر app نمیتواند در آن بنویسد. راهحل: COPY --chown=app:app یا chown روی مسیرهای لازم.
۳) چسباندن راز در ENV «فقط موقت»
Section titled “۳) چسباندن راز در ENV «فقط موقت»”مثال ۳: لایههای image همیشه میمانند. راهحل: --mount=type=secret برای build و -e/secret برای اجرا.
۴) --cap-drop ALL و خطای عجیب
Section titled “۴) --cap-drop ALL و خطای عجیب”docker run --rm --cap-drop ALL nginx:alpine 2>&1 | grep -m1 'nginx: \[emerg\]'nginx: [emerg] chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)nginx رسمی با root شروع میکند، پوشههایش را chown میکند و بعد به کاربر nginx میرود؛ بدون capability های CHOWN، SETUID و SETGID از همان اول میشکند، با خطایی که ربطی به «امنیت» به نظر نمیرسد (Operation not permitted). راهحل: خطا را بخوان و فقط همان capability ها را با --cap-add برگردان (اینجا CHOWN، SETUID، SETGID و NET_BIND_SERVICE؛ این ترکیب را تست کردم و nginx بالا آمد)، یا از imageای استفاده کن که از اول بدون root اجرا میشود؛ نه --privileged.
۵) تصور «کانتینر یعنی ایمن»
Section titled “۵) تصور «کانتینر یعنی ایمن»”کانتینر هسته را با میزبان مشترک میخواند؛ آسیبپذیری هسته یا تنظیم بد میتواند مرز را بشکند. راهحل: بهروز بودن، دفاع لایهای (این درس) و اسکن آسیبپذیری (درس بعد).
یک کانتینر alpine را با کاربر nobody اجرا کن و نشان بده نمیتواند در /etc بنویسد.
دیدن جواب
docker run --rm --user nobody alpine sh -c 'id; echo x > /etc/test 2>&1 || true'uid=65534(nobody) gid=65534(nobody) groups=65534(nobody)sh: can't create /etc/test: Permission deniedتمرین اصلی: یک کانتینر را با کمترین دسترسی اجرا کن. python:3.12-alpine را با کاربر 10001، فایلسیستم فقطخواندنی، /tmp قابلنوشتن، بدون capability، با no-new-privileges اجرا کن و با یک اسکریپت بررسی کن که هر پنج ویژگی واقعاً فعال است (PASS/FAIL).
دیدن جواب
docker run -d --name lx-sec3 --user 10001:10001 --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges python:3.12-alpine sleep 60 >/dev/nullcheck() { if [ "$2" = "$3" ]; then echo "PASS $1"; else echo "FAIL $1 (got: $2)"; fi; }check "کاربر غیر root" "$(docker exec lx-sec3 id -u)" "10001"check "فایلسیستم فقطخواندنی" "$(docker inspect lx-sec3 --format '{{.HostConfig.ReadonlyRootfs}}')" "true"check "بدون capability" "$(docker exec lx-sec3 sh -c 'grep CapEff /proc/self/status' | awk '{print $2}')" "0000000000000000"check "no-new-privileges" "$(docker inspect lx-sec3 --format '{{.HostConfig.SecurityOpt}}')" "[no-new-privileges]"check "/tmp قابلنوشتن" "$(docker exec lx-sec3 sh -c 'echo x > /tmp/f && echo ok')" "ok"docker rm -f lx-sec3 >/dev/nullPASS کاربر غیر rootPASS فایلسیستم فقطخواندنیPASS بدون capabilityPASS no-new-privilegesPASS /tmp قابلنوشتنثابت کن یک راز BuildKit در image نمیماند ولی یک ENV میماند. دو image بساز (یکی با ENV TOKEN=... و یکی با --mount=type=secret) و در هر دو، با docker save و grep -r، بهدنبال راز بگرد.
دیدن جواب
mkdir -p e3 && cd e3printf 'FROM alpine\nENV TOKEN=needle-12345\n' > env.Dockerfileprintf 'FROM alpine\nRUN --mount=type=secret,id=t wc -c < /run/secrets/t >/dev/null\n' > sec.Dockerfileprintf 'needle-12345' > t.txtdocker build -q -f env.Dockerfile -t lx-e3-env . >/dev/nulldocker build -q -f sec.Dockerfile --secret id=t,src=t.txt -t lx-e3-sec . >/dev/nullfor i in env sec; do docker save -o $i.tar lx-e3-$i; mkdir -p x-$i; tar -xf $i.tar -C x-$i echo "$i: $(grep -rl --binary-files=text needle-12345 x-$i | wc -l | tr -d ' ') فایل شامل راز"donedocker rmi lx-e3-env lx-e3-sec >/dev/nullenv: 1 فایل شامل رازsec: 0 فایل شامل رازآزمونک
Section titled “آزمونک”اگر در Dockerfile دستور USER نباشد، پروسه با چه کاربری اجرا میشود؟
پیشفرض root است؛ USER را بنویس.
چرا ENV PASSWORD=... در Dockerfile خطرناک است؟
از --mount=type=secret برای build و -e/secret برای اجرا.
docker run --read-only چه میکند؟
مهاجم نمیتواند فایلهای اپ را عوض کند.
اصل least privilege برای capabilities یعنی…
قدرت اضافه یعنی دامنهی خسارت بیشتر.
چرا mount کردن /var/run/docker.sock خطرناک است؟
فقط برای ابزار قابلاعتماد و آگاهانه.
no-new-privileges چه میکند؟
با USER غیر root و cap-drop ترکیب میشود.
جمعبندی
Section titled “جمعبندی”- کاربر غیر root:
USER app(ترجیحاً uid عددی) وCOPY --chown؛ بدونUSER، root است. - رازها هرگز در
ENV/ARG/لایهی image؛ زمان build:--mount=type=secret؛ زمان اجرا: env-file یا secret. --read-onlyبا--tmpfsبرای مسیرهای موقت؛ مسیرهای قابلنوشتن را مشخص کن.--cap-drop ALLو فقط capability لازم را با--cap-add؛ هرگز--privilegedوdocker.sockرا بیحساب نده.--security-opt no-new-privileges،--pids-limit،--memoryلایههای دفاعی ارزاناند.- رازی که لو رفت را عوض کن؛ حذف image کافی نیست.
| دستور | کاری که میکند |
|---|---|
USER app | اجرای پروسه با کاربر غیر root |
COPY --chown=app:app . . | فایلها مال کاربر اپ |
docker run --user 10001:10001 IMG | کاربر در زمان اجرا |
docker run --read-only --tmpfs /tmp IMG | فایلسیستم فقطخواندنی |
docker run --cap-drop ALL --cap-add CHOWN IMG | حداقل capability |
docker run --security-opt no-new-privileges IMG | جلوگیری از افزایش دسترسی |
RUN --mount=type=secret,id=t … | راز زمان build (در image نمیماند) |
docker build --secret id=t,src=file . | دادن راز به build |
docker run --pids-limit 100 --memory 128m IMG | حد پروسه و حافظه |