Foundationপ্রথম নীতি থেকে
LEVEL 10লেসন ১৭/২০অ্যাডভান্সড৫০ মিনিট

Sandboxing ও Isolation — seccomp, Capabilities, আর SELinux

Sandboxing and Isolation: seccomp, Capabilities, and Mandatory Access Control

namespace আর cgroup একটা process কী দেখতে পায় আর কতটুকু resource ব্যবহার করতে পারে তা নিয়ন্ত্রণ করে, কিন্তু কোন syscall সে আদৌ চেষ্টা করতে পারে তা নিয়ন্ত্রণ করে না — seccomp সেই ফাঁক ভরাট করে syscall allowlist দিয়ে, capabilities root-এর সর্বব্যাপী ক্ষমতাকে ৪১টা গ্রহণযোগ্য bit-এ ভাঙে, আর SELinux-এর মতো Mandatory Access Control root নিজেকেও একটা নীতি-নির্ধারিত সীমার ভেতরে বেঁধে ফেলে — এই পাঁচটা স্তর একসাথেই defense-in-depth-এর বাইরের দেয়াল তৈরি করে।

এই লেসন শেষে আপনি পারবেন

  • seccomp-bpf কীভাবে প্রতিটা syscall entry-তে kernel-এর ভেতরে একটা BPF প্রোগ্রাম চালিয়ে allowlist/denylist যাচাই করে, আর কেন এটা namespace/cgroup-এর চেয়ে সম্পূর্ণ ভিন্ন স্তরের সুরক্ষা দেয় তা ব্যাখ্যা করতে পারবেন
  • একটা বাস্তব, minimal সার্ভিসের জন্য একটা concrete syscall allowlist টেবিল বানিয়ে, কোন syscall বিপজ্জনক (execve, ptrace, mount) আর কেন সেগুলো বাদ দেওয়া উচিত তা যুক্তি দিয়ে ব্যাখ্যা করতে পারবেন
  • Linux capabilities কীভাবে root-এর সর্বব্যাপী, all-or-nothing ক্ষমতাকে প্রায় ৪১টা স্বতন্ত্র bit-এ ভেঙে দেয়, আর একটা concrete least-privilege উদাহরণ (CAP_NET_BIND_SERVICE) দিয়ে সেই granularity-র বাস্তব সুবিধা প্রমাণ করে দেখাতে পারবেন
  • Discretionary Access Control (DAC) আর Mandatory Access Control (MAC)-এর মৌলিক পার্থক্য কী, আর SELinux-এর type enforcement ধারণা একটা concrete উদাহরণ দিয়ে ব্যাখ্যা করে কেন MAC root-কেও সীমাবদ্ধ করতে পারে তা প্রমাণ করতে পারবেন
  • namespace, cgroup, seccomp, capabilities, আর MAC — এই পাঁচটা স্তর কীভাবে একসাথে স্ট্যাক হয়ে একটা প্রোডাকশন সিস্টেমে 'hardened' শব্দটার আসল অর্থ তৈরি করে তা সংশ্লেষণ করে ব্যাখ্যা করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

আগের লেসনে একটা প্রক্রিয়াকে সম্পূর্ণ বিভ্রম দেওয়া হয়েছিল যে সে একাই একটা মেশিনে আছে — namespace দিয়ে সে কী দেখতে পায় তা নিয়ন্ত্রণ করে, cgroup দিয়ে সে কতটুকু CPU/memory/PID ব্যবহার করতে পারে তা নিয়ন্ত্রণ করে। সেই লেসনের শেষে একটা সাবধানবাণীও ছিল — namespace কোনো kernel object না, শুধু একটা task_struct-এর কিছু pointer, আর “container escape” মানে শেষমেশ একটা সাধারণ kernel-privilege bug।

আজ ঠিক সেই ফাঁকটার ওপর দাঁড়িয়ে একটা নতুন প্রশ্ন করা হবে। namespace আর cgroup — দুটোর কোনোটাই একটা মৌলিক প্রশ্নের উত্তর দেয় না: এই process আসলে কোন কোন syscall চালানোর চেষ্টা করতে পারে? namespace-এর ভেতরে বসে থাকা একটা process এখনও mount(), ptrace(), reboot(), init_module() — এসব syscall চেষ্টা করতে পারে (namespace-এর নিজস্ব capability-check তাদের ব্যর্থ করতে পারে, কিন্তু syscall dispatch-এ পৌঁছানো আটকায় না)। cgroup-এর memory.max একটা process-কে ২০ MB-র বেশি ব্যবহার করতে দেয় না, কিন্তু সেই ২০ MB-র ভেতরে বসেই সে চাইলে execve("/bin/sh") চালাতে পারে।

আজকের লেসন defense-in-depth-এর বাইরের বলয় — process নিজে কী করতে পারবে, আগে থেকেই সেই তালিকাটা সংকুচিত করে দেওয়া। namespace আর cgroup বসে ছিল “visibility” আর “resource” এর ওপর — আজকের তিনটা mechanism বসবে সরাসরি policy-র ওপর: কোন syscall চলবে (seccomp), root-এর ক্ষমতা কতটা ভাঙা থাকবে (capabilities), আর ফাইল/network-এর ওপর কার কী অধিকার আছে তা কে চূড়ান্তভাবে ঠিক করবে — file owner, নাকি একটা system-wide নীতি (Mandatory Access Control)।

মূল ধারণা

পাঁচটা স্তর, পাঁচটা ভিন্ন প্রশ্নের উত্তর

আগের লেসনের দুটো mechanism-এর সাথে আজকের তিনটা মিলিয়ে একটা সম্পূর্ণ টেবিল বানানো যাক — প্রতিটা স্তর ঠিক একটা নির্দিষ্ট প্রশ্নের উত্তর দেয়, আর কোনোটাই বাকিগুলোর কাজ করে না:

স্তরকোন প্রশ্নের উত্তর দেয়কে enforce করে
Namespaceএই process কী দেখতে পায়?kernel-এর প্রতিটা subsystem, nsproxy-র মাধ্যমে
Cgroupএই process কতটুকু resource ব্যবহার করতে পারে?scheduler, page-fault handler, block layer, fork path
seccompএই process কোন syscall আদৌ চালাতে পারে?syscall entry-তে বসানো BPF প্রোগ্রাম
Capabilitiesroot-এর কোন নির্দিষ্ট ক্ষমতা এই process-এর আছে?প্রতিটা privileged অপারেশনের নিজস্ব capable() চেক
MAC (SELinux)এই process কোন object-এর ওপর কী করতে পারে, owner যা-ই বলুক?LSM hook, প্রতিটা file/socket access-এ

লক্ষ্য করুন ক্রমটা — namespace আর cgroup আগের লেসনের বিষয়, “visibility” আর “quantity” নিয়ন্ত্রণ করে। আজকের তিনটা “action” আর “authority” নিয়ন্ত্রণ করে। একটা process পুরোপুরি namespaced আর cgroup-limited হয়েও, যদি তার execve() চালানোর permission থাকে, একটা /bin/sh চালিয়ে সেই namespace-এর ভেতরেই স্বাধীনভাবে ঘুরে বেড়াতে পারবে — namespace তাকে থামায় না, কারণ থামানো তার কাজই না।

Recap — namespace/cgroup যা বন্ধ করে না

আগের লেসনের docker run alpine sh উদাহরণটা আবার নেওয়া যাক। namespace আর cgroup চালু থাকা অবস্থায়ও, container-এর ভেতরের process-টা এখনও নিচের সবগুলো syscall চেষ্টা করতে পারে (সফল হবে কিনা ভিন্ন প্রশ্ন):

execve("/bin/sh", ...)        -- একটা নতুন shell চালানো
ptrace(PTRACE_ATTACH, ...)    -- অন্য একটা process-এর মেমরি পড়া/লেখা
mount("/dev/sda1", "/mnt")    -- নতুন কিছু mount করা
reboot(LINUX_REBOOT_CMD_...)  -- সিস্টেম রিবুট করার চেষ্টা
init_module(...)              -- একটা kernel module লোড করা

কিছু এখানেই আটকাবে — namespace-এর নিজস্ব capability check (ns_capable()) অনেক ক্ষেত্রে ব্যর্থ হবে, বিশেষ করে যদি container rootless বা capability-drop করা থাকে (আজকের পরের অংশের বিষয়)। কিন্তু এই আটকানোটা namespace বা cgroup নিজে করছে না — এটা করছে সম্পূর্ণ আলাদা একটা চেক, যেটা এখনও চালু করা হয়নি। এই লেসনের বাকি অংশ ঠিক সেই চেকগুলো নিয়েই।

ভেতরে কী ঘটছে

seccomp-bpf — syscall entry-তে বসানো একটা ফায়ারওয়াল

seccomp (secure computing mode) শুরু হয়েছিল ২০০৫ সালে খুবই সংকীর্ণ একটা মোডে — চালু করলে একটা process শুধু চারটা syscall চালাতে পারত (read, write, exit, sigreturn), বাকি সব-ই সাথে সাথে SIGKILL। ২০১২ সালে seccomp-bpf এসে এই কঠোরতাকে programmable বানিয়ে দিল — এখন একজন প্রোগ্রামার নিজের একটা নীতি লিখে দিতে পারেন, একটা BPF (Berkeley Packet Filter) প্রোগ্রাম হিসেবে, যেটা kernel প্রতিটা syscall entry-তে চালায়।

মেকানিজমটা এরকম: প্রতিটা syscall চেষ্টার সময় kernel একটা struct seccomp_data বানায় — কোন syscall (nr), কোন architecture (arch), আর তার ছয়টা argument। BPF প্রোগ্রাম এই struct-টা পরীক্ষা করে একটা verdict ফেরত দেয়, syscall আসলে execute হওয়ার আগেই:

struct seccomp_data {
    int    nr;                 -- syscall number (যেমন execve = 59)
    __u32  arch;                -- audit architecture (x86-64, arm64, ...)
    __u64  instruction_pointer;
    __u64  args[6];             -- syscall-এর ছয়টা argument, raw মান
};
Verdictঅর্থ
SECCOMP_RET_ALLOWsyscall স্বাভাবিকভাবে চলবে
SECCOMP_RET_ERRNOsyscall আসলে চলে না — একটা নির্দিষ্ট errno (যেমন EPERM) ফেরত আসে, process বেঁচে থাকে
SECCOMP_RET_KILL_PROCESSপুরো process সাথে সাথে SIGKILL
SECCOMP_RET_TRAPএকটা SIGSYS সিগন্যাল পাঠানো হয় — debugging/logging-এর জন্য
SECCOMP_RET_LOGsyscall চলে, কিন্তু audit log-এ একটা এন্ট্রি লেখা হয়

একটা গুরুত্বপূর্ণ ডিজাইন-সিদ্ধান্ত: filter একবার attach হয়ে গেলে সেটা আর সরানো যায় না, আর সেটা fork()/clone()/execve() জুড়েও উত্তরাধিকারসূত্রে টিকে থাকে (আরও কড়া হতে পারে, শিথিল হতে পারে না)। এটাই নিশ্চিত করে একবার একটা প্রক্রিয়া sandbox-এ ঢুকলে, নিজের কোনো পরবর্তী কোড-পাথ দিয়ে সেই sandbox থেকে নিজেকে বের করে আনতে পারবে না।

Linux capabilities — root-কে ৪১টা টুকরোয় ভাঙা

Traditional Unix permission model একেবারে দ্বিমুখী: হয় UID 0 (root, সবকিছু পারে), নয় সাধারণ UID (file permission bit-এর মধ্যে বাঁধা)। এই “সব-অথবা-কিছুই-না” মডেলের একটা ব্যবহারিক সমস্যা — ping কমান্ডের একটা raw ICMP socket খুলতে root privilege লাগে, কিন্তু ঐতিহাসিকভাবে সেটা করতে পুরো binary-কেই SUID-root বানিয়ে দেওয়া হতো — একটা ছোট্ট কাজের জন্য সম্পূর্ণ root ক্ষমতা।

Linux capabilities (kernel 2.2 থেকে, POSIX draft capabilities থেকে অনুপ্রাণিত) root-এর একচেটিয়া ক্ষমতাকে প্রায় ৪১টা স্বতন্ত্র bit-এ ভেঙে দেয় — প্রতিটা bit একটা নির্দিষ্ট শ্রেণীর privileged অপারেশন প্রতিনিধিত্ব করে:

Capabilityকী অনুমতি দেয়
CAP_NET_BIND_SERVICE1024-এর নিচের port-এ bind করা
CAP_NET_RAWraw socket খোলা (যেমন ping-এর ICMP)
CAP_CHOWNযেকোনো ফাইলের owner বদলানো
CAP_DAC_OVERRIDEfile permission bit উপেক্ষা করে read/write
CAP_SETUID / CAP_SETGIDনিজের UID/GID বদলানো
CAP_SYS_PTRACEঅন্য process-কে ptrace() করা
CAP_SYS_ADMINঐতিহাসিকভাবে “বাকি সব কিছু” — mount, বেশিরভাগ admin অপারেশন
CAP_SYS_MODULEkernel module লোড/আনলোড করা
CAP_SYS_BOOTreboot() চালানো
CAP_KILLনিজের UID না মিললেও signal পাঠানো

kernel-এর ভেতরে প্রতিটা privileged অপারেশন নিজের জায়গায় একটা capable(CAP_XXX) চেক বসায় — ping-এর জন্য raw socket খোলার কোড পরীক্ষা করে শুধু CAP_NET_RAW আছে কিনা, পুরো UID 0 কিনা তা না। ফলে আজকের ping binary-কে আর SUID-root বানানোর দরকার নেই — শুধু তার executable-এ CAP_NET_RAW ফাইল-capability সেট করে দিলেই যথেষ্ট (setcap cap_net_raw+ep /bin/ping)।

আগের লেসনের user namespace-এর সাথে একটা গুরুত্বপূর্ণ যোগসূত্র এখানে স্পষ্ট করা দরকার: একটা process যদি নিজের user namespace-এ UID 0-এ mapped থাকে, সে সেই namespace-এর ভেতরে সব capability পেতে পারে (namespace-এর নিজস্ব root হিসেবে) — কিন্তু capability bounding set যদি আলাদাভাবে সংকুচিত করা থাকে (যেমন Docker-এর ডিফল্ট ১৪-capability সেট, আজকের build অংশে দেখা যাবে), namespace-এর ভেতরের সেই “root”-ও bounding set-এর বাইরে কিছু করতে পারবে না। namespace-এর ভেতরে UID 0 হওয়া আর bounding-set-এ সব capability থাকা — সম্পূর্ণ স্বাধীন দুইটা সীমা, দুটোই আলাদাভাবে সংকুচিত করা দরকার।

উদাহরণ

একটা concrete syscall allowlist — minimal static-file HTTP server

Abstraction ছেড়ে একটা বাস্তব সংখ্যায় নামা যাক। একটা ন্যূনতম, শুধু static ফাইল পরিবেশনকারী HTTP server-এর normal অপারেশনের জন্য ঠিক কোন syscall-গুলো লাগে? নিচের টেবিলটা একটা বাস্তবসম্মত allowlist — strace-এ এই ধরনের server-এর trace-এ যা সাধারণত দেখা যায় তার ওপর ভিত্তি করে তৈরি (কোনো live strace capture না, কিন্তু প্রতিটা entry বাস্তব, পরিচিত syscall আর তার বাস্তব ব্যবহারিক কারণ):

Syscallকেন দরকার
epoll_wait, epoll_ctlনতুন connection বা readable socket-এর জন্য অপেক্ষা
accept4একটা নতুন TCP connection গ্রহণ
read, readv, recvfromclient request পড়া
write, writev, sendtoresponse পাঠানো
openat, closedisk থেকে static ফাইল খোলা/বন্ধ করা
newfstatat, lseekফাইলের আকার/metadata জানা
mmap, munmap, brkমেমরি allocate করা (buffer, ফাইল-ম্যাপিং)
socket, bind, listenlistening socket তৈরি (শুধু startup-এ)
rt_sigaction, rt_sigprocmasksignal handler বসানো (graceful shutdown)
futex, clone, set_robust_listworker thread ব্যবস্থাপনা
getrandomTLS session key-জাতীয় randomness
exit, exit_groupপ্রক্রিয়া শেষ হওয়া

আর ঠিক ততটাই গুরুত্বপূর্ণ — এই তালিকায় নেই এমন কয়েকটা syscall, যেগুলো একটা স্বাভাবিক static-file server-এর জীবনে কখনোই দরকার হয় না, অথচ প্রায় প্রতিটা post-exploitation কৌশলের কেন্দ্রে থাকে:

Syscallকেন বাদকে সাধারণত এটা চায়
execveServer নিজে কখনো নতুন প্রোগ্রাম চালায় নাএকটা shell/reverse-shell স্পন করার আক্রমণ
ptraceServer অন্য কোনো process debug করে নাএকটা sibling process থেকে secret চুরি
mount, pivot_rootServer নিজের filesystem tree বদলায় নাএকটা container escape কৌশল
init_module, delete_moduleServer kernel module ছোঁয় নাkernel-level persistence/rootkit
rebootServer সিস্টেম রিবুট করে নাdenial-of-service
keyctl, add_keyServer kernel keyring ব্যবহার করে নাcredential harvesting

এই দুইটা টেবিলই একসাথে একটা seccomp allowlist-এর কঙ্কাল — প্রথমটা SCMP_ACT_ALLOW, বাকি সব-কিছু (দ্বিতীয় টেবিল-সহ) ডিফল্টে SCMP_ACT_ERRNO/SCMP_ACT_KILL। পরের experiment অংশে এই ঠিক এই নীতিটাই কোডে বসিয়ে, দুটো বাস্তবসম্মত syscall-trace-এর বিপরীতে চালিয়ে দেখানো হবে।

Capability bit-space — সংখ্যায় কতটা সূক্ষ্ম

নিচের কোডটা আসলেই চালানো — root-এর “হয়/না” দ্বিমুখী মডেলের বিপরীতে capabilities ঠিক কতটা বেশি granularity দেয় তা সংখ্যায় দেখায়:

# capabilities_math.py -- how much finer-grained is "41 independent
# capability bits" than "UID==0 or not"? Pure arithmetic, run for real.

TOTAL_CAPS = 41  # CAP_CHOWN(0) .. CAP_CHECKPOINT_RESTORE(40); capabilities(7)

root_model_states = 2          # traditional DAC: either UID 0 (all power) or not (none)
cap_model_states = 2 ** TOTAL_CAPS  # every capability independently on/off

print(f"traditional root model : {root_model_states} distinct privilege states (root / not-root)")
print(f"capability model       : 2^{TOTAL_CAPS} = {cap_model_states:,} distinct privilege states")
print(f"ratio                  : {cap_model_states // root_model_states:,}x more granular")
print()

# a concrete, realistic "least privilege" profile: a server that must bind
# to a low port (<1024) but do nothing else root-like.
NEEDED = {"CAP_NET_BIND_SERVICE"}
ALL_EXAMPLE_CAPS_SAMPLE = {
    "CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_KILL", "CAP_SETUID",
    "CAP_SETGID", "CAP_NET_BIND_SERVICE", "CAP_NET_RAW", "CAP_SYS_ADMIN",
    "CAP_SYS_MODULE", "CAP_SYS_PTRACE", "CAP_SYS_BOOT", "CAP_MKNOD",
}
dropped = sorted(ALL_EXAMPLE_CAPS_SAMPLE - NEEDED)
print(f"a low-port HTTP server needs exactly: {sorted(NEEDED)}")
print(f"out of a {len(ALL_EXAMPLE_CAPS_SAMPLE)}-capability illustrative sample, it can drop {len(dropped)}:")
for c in dropped:
    print(f"  - {c}")

প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):

traditional root model : 2 distinct privilege states (root / not-root)
capability model       : 2^41 = 2,199,023,255,552 distinct privilege states
ratio                  : 1,099,511,627,776x more granular

a low-port HTTP server needs exactly: ['CAP_NET_BIND_SERVICE']
out of a 13-capability illustrative sample, it can drop 12:
  - CAP_CHOWN
  - CAP_DAC_OVERRIDE
  - CAP_FOWNER
  - CAP_KILL
  - CAP_MKNOD
  - CAP_NET_RAW
  - CAP_SETGID
  - CAP_SETUID
  - CAP_SYS_ADMIN
  - CAP_SYS_BOOT
  - CAP_SYS_MODULE
  - CAP_SYS_PTRACE

সংখ্যাটা নিজেই যুক্তিটা প্রমাণ করে — ২^৪১ প্রায় এক ট্রিলিয়ন গুণ বেশি রাজ্য (state) তৈরি করে দ্বিমুখী মডেলের তুলনায়। বাস্তব অর্থ: একটা server যার শুধু একটা low port bind করার দরকার, তাকে আজও পুরো root বানানোর কোনো কারণ নেই — মাত্র একটা bit চালু রেখে বাকি ১২টা (এই ছোট্ট উদাহরণেই) সম্পূর্ণ বন্ধ রাখা যায়।

ছবিতে

DAC বনাম MAC — মালিকানা বনাম নীতি

process (root, UID 0) চেষ্টা করছে /var/www/secret.conf পড়তে
        │
        ▼
[ধাপ ১ — DAC: Discretionary Access Control]
   file permission bit (rwxr-x---), owner/group যাচাই
   root হলে DAC প্রায় সবসময় পাস — এটাই "discretionary": ফাইলের
   *owner* ঠিক করে কে ঢুকতে পারবে, আর root ঐতিহাসিকভাবে সেই
   সিদ্ধান্তকে override করতে পারে (CAP_DAC_OVERRIDE)
        │  (DAC পাস — root বলে)
        ▼
[ধাপ ২ — MAC: Mandatory Access Control, যেমন SELinux]
   security context যাচাই: এই process কোন domain-এ আছে?
   এই ফাইল কোন type-এর? policy-তে কি domain -> type অনুমতি লেখা আছে?
   এই ধাপ *ব্যবহারকারীর ইচ্ছার বাইরে* — root নিজেও policy বদলাতে
   না পারলে override করতে পারে না
        │
        ▼
   MAC policy-তে অনুমতি লেখা থাকলেই -> ACCESS ALLOWED
   না থাকলে -> EACCES, root হলেও
একটা ফাইল-access সিদ্ধান্তে DAC আর MAC কীভাবে দুইটা ভিন্ন, ধারাবাহিক গেট হিসেবে কাজ করে।

মূল পার্থক্যটা এক লাইনে: DAC-এ ফাইলের owner সিদ্ধান্ত নেয় (তাই “discretionary” — owner-এর ইচ্ছাধীন), MAC-এ একটা কেন্দ্রীয়, system-wide নীতি সিদ্ধান্ত নেয়, আর সেই নীতি owner বা এমনকি root নিজেও একতরফাভাবে override করতে পারে না। SELinux-এর মূল কৌশলের নাম type enforcement — প্রতিটা process একটা domain-এ চলে (যেমন httpd_t), প্রতিটা resource (ফাইল, socket, ডিভাইস)-এর একটা type থাকে (যেমন httpd_content_t), আর policy-তে স্পষ্টভাবে লেখা থাকে কোন domain কোন type-এর ওপর কোন অপারেশন (read, write, execute, connect…) চালাতে পারে। policy-তে না লেখা থাকা যেকোনো কম্বিনেশন ডিফল্টভাবে denied — এটাই default-deny নীতি, যা DAC-এর ঐতিহ্যগত default-allow (owner অন্য কিছু না বললে root/owner-এর পুরো অধিকার) থেকে সম্পূর্ণ উল্টো দিকের দর্শন।

একটা hardened HTTP server-এ post-RCE attacker — পাঁচটা স্তরের প্রতিটাতেই একটা আলাদা বাধা
  1. parser bug -> RCE primitiveআগের memory-safety লেসনগুলোর ধরনের একটা bug — attacker এখন এই process-এর ভেতরে নির্বিচারে syscall চালানোর ক্ষমতা পেয়ে গেছে
  2. namespaceattacker শুধু container-এর নিজস্ব mount/net/PID namespace দেখে — host-এর real filesystem/process list তার কাছে অদৃশ্য
  3. cgroupযদি attacker fork-bomb বা memory-exhaustion চেষ্টা করে, pids.max/memory.max সেটাকেও সীমাবদ্ধ রাখে
  4. seccompexecve()/ptrace()/mount() চেষ্টা করলেই allowlist-এ নেই -- SCMP_ACT_KILL, প্রক্রিয়া তৎক্ষণাৎ শেষ
  5. capabilitiesseccomp bypass করা কোনোভাবে সম্ভব হলেও, bounding set-এ CAP_SYS_ADMIN/CAP_SYS_MODULE নেই -- privileged অপারেশন এখনও EPERM
  6. MAC (SELinux)এমনকি DAC অনুমতি থাকলেও, hardened_httpd_t domain-এর policy-তে অন্য কোনো content-type-এ write অনুমোদিত না -- EACCES

লক্ষ্য করুন — এই ছয়টা ধাপের কোনোটাই একা পুরো আক্রমণ থামানোর জন্য ডিজাইন করা হয়নি। প্রতিটাই একটা নির্দিষ্ট ধরনের bypass ঠেকায়; attacker-কে সফল হতে হলে প্রতিটা স্তর আলাদাভাবে ভাঙতে হবে — ঠিক আগের লেসনের canary+ASLR+NX+CFI-এর মতোই গুণিতক খরচের যুক্তি, শুধু এবার memory-corruption স্তরে না, policy স্তরে।

নিজে চালিয়ে দেখুন

EXPERIMENT

seccomp allowlist সিমুলেশন — normal trace বনাম post-RCE attacker trace

যেকোনো OS (Python 3) — সিমুলেশন, লাইভ kernel filter না· ১৫ মিনিট
# seccomp_sim.py -- models the REAL decision algorithm the kernel runs for
# every syscall once a seccomp-bpf filter is attached (seccomp(2) + BPF
# program evaluated in-kernel against `struct seccomp_data { nr; arch;
# instruction_pointer; args[6]; }`). This is a Python model of that decision
# logic -- NOT a live kernel filter. Actually installing a seccomp-bpf
# program requires Linux + prctl(PR_SET_NO_NEW_PRIVS)/seccomp(2), which this
# macOS/Darwin agent sandbox cannot do; libseccomp's own rule-precedence
# semantics (first added, most specific match wins; unmatched -> default
# action) are reproduced faithfully here.

SCMP_ACT_ALLOW = "ALLOW"
SCMP_ACT_ERRNO = "ERRNO(EPERM)"
SCMP_ACT_KILL  = "KILL_PROCESS"

class SeccompFilter:
    """Ordered rule list + default action, mirroring libseccomp's
    seccomp_init(default_action) + seccomp_rule_add(action, syscall, arg...)."""
    def __init__(self, default_action):
        self.default_action = default_action
        self.rules = []  # list of (syscall_name, arg_check_or_None, action)

    def add_rule(self, action, syscall_name, arg_check=None):
        self.rules.append((syscall_name, arg_check, action))

    def decide(self, syscall_name, args):
        for name, check, action in self.rules:
            if name != syscall_name:
                continue
            if check is None or check(args):
                return action
        return self.default_action


# ---------- policy for a minimal static-file HTTP server ----------
policy = SeccompFilter(default_action=SCMP_ACT_KILL)  # allowlist: unknown -> kill

# arg-constrained rule: socket() only for AF_INET(2)/AF_INET6(10), never
# AF_PACKET(17) (raw packet capture) or AF_NETLINK(16, used to talk to the
# kernel's routing/netfilter machinery).
policy.add_rule(SCMP_ACT_ALLOW, "socket", lambda a: a[0] in (2, 10))
for name in ["bind", "listen", "accept4", "epoll_create1", "epoll_ctl",
             "epoll_wait", "read", "write", "readv", "writev", "sendto",
             "recvfrom", "close", "openat", "fstat", "newfstatat", "lseek",
             "mmap", "munmap", "brk", "rt_sigaction", "rt_sigprocmask",
             "rt_sigreturn", "futex", "clone", "set_robust_list",
             "getrandom", "prlimit64", "arch_prctl", "set_tid_address",
             "exit", "exit_group", "poll", "fcntl"]:
    policy.add_rule(SCMP_ACT_ALLOW, name)

# ---------- trace 1: a normal request/response cycle ----------
normal_trace = [
    ("epoll_wait", []), ("accept4", []), ("fcntl", []),
    ("readv", []), ("openat", []), ("newfstatat", []),
    ("mmap", []), ("sendto", []), ("close", []), ("munmap", []),
]

# ---------- trace 2: a post-RCE attacker trying to pivot from the SAME
# process (e.g. after a parser bug gave them a write-what-where primitive) ----------
attacker_trace = [
    ("read", []),                      # still legitimate -- part of normal flow
    ("mmap", []),                      # still legitimate -- e.g. allocating a buffer
    ("execve", ["/bin/sh"]),           # (1) spawn a shell
    ("ptrace", []),                    # (2) if execve was blocked, try attaching to a sibling
    ("mount", []),                     # (3) try to remount something rw / escape
    ("socket", [16]),                  # (4) AF_NETLINK -- probe/manipulate routing
    ("socket", [2]),                   # (5) AF_INET reverse-shell connect-back
]

def run_trace(name, trace, policy):
    print(f"=== {name} ===")
    killed = False
    for syscall, args in trace:
        if killed:
            print(f"  {syscall:14s} args={args!s:10s} -> (never reached, process already dead)")
            continue
        verdict = policy.decide(syscall, args)
        print(f"  {syscall:14s} args={args!s:10s} -> {verdict}")
        if verdict == SCMP_ACT_KILL:
            killed = True
    print()

run_trace("normal request/response cycle", normal_trace, policy)
run_trace("post-RCE attacker pivot attempt", attacker_trace, policy)

allowed = sorted({r[0] for r in policy.rules if r[2] == SCMP_ACT_ALLOW})
print(f"policy allowlist size = {len(allowed)} distinct syscalls")
print(f"default action for anything else = {policy.default_action}")

প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):

=== normal request/response cycle ===
  epoll_wait     args=[]         -> ALLOW
  accept4        args=[]         -> ALLOW
  fcntl          args=[]         -> ALLOW
  readv          args=[]         -> ALLOW
  openat         args=[]         -> ALLOW
  newfstatat     args=[]         -> ALLOW
  mmap           args=[]         -> ALLOW
  sendto         args=[]         -> ALLOW
  close          args=[]         -> ALLOW
  munmap         args=[]         -> ALLOW

=== post-RCE attacker pivot attempt ===
  read           args=[]         -> ALLOW
  mmap           args=[]         -> ALLOW
  execve         args=['/bin/sh'] -> KILL_PROCESS
  ptrace         args=[]         -> (never reached, process already dead)
  mount          args=[]         -> (never reached, process already dead)
  socket         args=[16]       -> (never reached, process already dead)
  socket         args=[2]        -> (never reached, process already dead)

policy allowlist size = 35 distinct syscalls
default action for anything else = KILL_PROCESS

attacker_trace-এর প্রথম দুইটা syscall (read, mmap) সম্পূর্ণ নিরীহ — allowlist-এ থাকায় ALLOW পেয়েছে, কারণ seccomp কনটেন্ট বা উদ্দেশ্য বোঝে না, শুধু কোন syscall চেষ্টা হচ্ছে তা দেখে। কিন্তু ঠিক যে মুহূর্তে execve আসে — সেটা allowlist-এ নেই, KILL_PROCESS ঘটে, আর তার পরের পাঁচটা syscall (ptrace, mount, দুইটা socket কল) কখনো পৌঁছায়ই না, কারণ process ততক্ষণে মৃত। এটাই allowlist-এর আসল শক্তি: attacker-এর পরের পরিকল্পনা যা-ই থাকুক, প্রথম নিষিদ্ধ পদক্ষেপেই পুরো chain থেমে যায়।

এটা কী প্রমাণ করে

একটা allowlist-ভিত্তিক seccomp নীতিতে normal-flow-এর প্রতিটা syscall অনুমোদিত থাকে, কিন্তু attacker যেই মুহূর্তে allowlist-এর বাইরের প্রথম syscall (execve) চেষ্টা করে, ঠিক সেখানেই KILL_PROCESS ঘটে -- তার পরের কোনো syscall (ptrace, mount, socket দিয়ে connect-back) কখনো পৌঁছায়ই না।

নিজে বানান

BUILD IT

HardeningProfileGenerator — একটা declarative policy থেকে তিনটা বাস্তব artifact তৈরি

Python · ●●●●○
  1. একটা service description dict বানান — নাম, allowed syscalls, প্রয়োজনীয় capabilities, SELinux domain/content-type
  2. সেখান থেকে একটা OCI/Docker-স্টাইল seccomp JSON profile তৈরি করুন (defaultAction, architectures, syscalls[])
  3. Docker-এর ডিফল্ট ১৪-capability সেট থেকে শুধু প্রয়োজনীয় capability রেখে বাকিগুলোর জন্য --cap-drop/--cap-add flag বানান
  4. একটা SELinux-স্টাইল type-enforcement rule স্তূপ তৈরি করুন যা শুধু নির্দিষ্ট content-type-এ read অনুমতি দেয়, write না
  5. তিনটা artifact-ই একসাথে প্রিন্ট করে দেখান কীভাবে namespace+cgroup+seccomp+capabilities+MAC একটা single "hardening profile"-এ মিলে যায়

লক্ষ্য: আগের সেকশনগুলোতে প্রতিটা mechanism আলাদা আলাদা করে দেখা হয়েছে। এখানে একটা ছোট “কম্পাইলার” লেখা হবে যা একটামাত্র declarative বর্ণনা থেকে তিনটা বাস্তব-ফরম্যাটের config বানায় — ঠিক যেভাবে বাস্তব CI/deployment pipeline-এ এই ধরনের জেনারেশন হয়।

# hardening_profile.py -- a tiny "compiler" from a declarative service
# description to three concrete, real-format hardening artifacts:
#   1. an OCI/Docker-style seccomp JSON profile
#   2. a capability drop/add list (docker run --cap-drop/--cap-add shape)
#   3. an SELinux type-enforcement rule stub (policy-language shape)
# Every string below matches the REAL syntax these tools expect --
# this script just assembles it from one small declaration, run for real.
import json

SERVICE = {
    "name": "static-file-http-server",
    "syscalls_allowed": [
        "epoll_wait", "accept4", "fcntl", "readv", "writev", "openat",
        "newfstatat", "mmap", "munmap", "brk", "sendto", "recvfrom",
        "close", "read", "write", "socket", "bind", "listen",
        "rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "futex",
        "clone", "set_robust_list", "getrandom", "prlimit64",
        "arch_prctl", "set_tid_address", "exit", "exit_group", "poll",
    ],
    "capabilities_needed": ["CAP_NET_BIND_SERVICE"],
    "selinux_domain": "hardened_httpd_t",
    "selinux_content_type": "hardened_httpd_content_t",
}

def build_seccomp_profile(service):
    return {
        "defaultAction": "SCMP_ACT_ERRNO",
        "architectures": ["SCMP_ARCH_X86_64"],
        "syscalls": [
            {"names": sorted(service["syscalls_allowed"]), "action": "SCMP_ACT_ALLOW"}
        ],
    }

ALL_DOCKER_DEFAULT_CAPS = [
    "CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "KILL", "SETGID",
    "SETUID", "SETPCAP", "NET_BIND_SERVICE", "NET_RAW", "SYS_CHROOT",
    "MKNOD", "AUDIT_WRITE", "SETFCAP",
]

def build_capability_flags(service):
    needed = {c.replace("CAP_", "") for c in service["capabilities_needed"]}
    dropped = sorted(set(ALL_DOCKER_DEFAULT_CAPS) - needed)
    flags = ["--cap-drop=ALL"] + [f"--cap-add={c}" for c in sorted(needed)]
    return flags, dropped

def build_selinux_rules(service):
    domain = service["selinux_domain"]
    content = service["selinux_content_type"]
    return [
        f"type {domain};",
        f"type {content};",
        f"allow {domain} {content}:file {{ read getattr open }};",
        f"allow {domain} self:tcp_socket {{ create bind listen accept read write }};",
        f"# NOTE: no rule grants {domain} write access to {content} --",
        f"# even the process's own UID-0-mapped root cannot bypass this,",
        f"# because MAC is checked in addition to, not instead of, DAC.",
    ]

profile = build_seccomp_profile(SERVICE)
print("--- 1. seccomp profile (OCI/Docker JSON shape) ---")
print(json.dumps(profile, indent=2))
print(f"\nallowlist size = {len(profile['syscalls'][0]['names'])} syscalls "
      f"(everything else -> SCMP_ACT_ERRNO)")

flags, dropped = build_capability_flags(SERVICE)
print("\n--- 2. capability flags (docker run shape) ---")
print(" ".join(flags))
print(f"dropped from Docker's own default set ({len(ALL_DOCKER_DEFAULT_CAPS)} caps): "
      f"{len(dropped)} -> {dropped}")

print("\n--- 3. SELinux type-enforcement stub ---")
for line in build_selinux_rules(SERVICE):
    print(line)

print("\n--- combined: what actually ships for this service ---")
print(f"namespaces : PID+mount+net+UTS+IPC+user (from the previous lesson)")
print(f"cgroup     : memory.max, cpu.max, pids.max (from the previous lesson)")
print(f"seccomp    : {len(profile['syscalls'][0]['names'])}-syscall allowlist, default ERRNO")
print(f"capabilities: bounding set reduced from {len(ALL_DOCKER_DEFAULT_CAPS)} to "
      f"{len(SERVICE['capabilities_needed'])} ({SERVICE['capabilities_needed'][0]})")
print(f"MAC        : confined to SELinux domain {SERVICE['selinux_domain']}")

প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):

--- 1. seccomp profile (OCI/Docker JSON shape) ---
{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": [
    "SCMP_ARCH_X86_64"
  ],
  "syscalls": [
    {
      "names": [
        "accept4",
        "arch_prctl",
        "bind",
        "brk",
        "clone",
        "close",
        "epoll_wait",
        "exit",
        "exit_group",
        "fcntl",
        "futex",
        "getrandom",
        "listen",
        "mmap",
        "munmap",
        "newfstatat",
        "openat",
        "poll",
        "prlimit64",
        "read",
        "readv",
        "recvfrom",
        "rt_sigaction",
        "rt_sigprocmask",
        "rt_sigreturn",
        "sendto",
        "set_robust_list",
        "set_tid_address",
        "socket",
        "write",
        "writev"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

allowlist size = 31 syscalls (everything else -> SCMP_ACT_ERRNO)

--- 2. capability flags (docker run shape) ---
--cap-drop=ALL --cap-add=NET_BIND_SERVICE
dropped from Docker's own default set (14 caps): 13 -> ['AUDIT_WRITE', 'CHOWN', 'DAC_OVERRIDE', 'FOWNER', 'FSETID', 'KILL', 'MKNOD', 'NET_RAW', 'SETFCAP', 'SETGID', 'SETPCAP', 'SETUID', 'SYS_CHROOT']

--- 3. SELinux type-enforcement stub ---
type hardened_httpd_t;
type hardened_httpd_content_t;
allow hardened_httpd_t hardened_httpd_content_t:file { read getattr open };
allow hardened_httpd_t self:tcp_socket { create bind listen accept read write };
# NOTE: no rule grants hardened_httpd_t write access to hardened_httpd_content_t --
# even the process's own UID-0-mapped root cannot bypass this,
# because MAC is checked in addition to, not instead of, DAC.

--- combined: what actually ships for this service ---
namespaces : PID+mount+net+UTS+IPC+user (from the previous lesson)
cgroup     : memory.max, cpu.max, pids.max (from the previous lesson)
seccomp    : 31-syscall allowlist, default ERRNO
capabilities: bounding set reduced from 14 to 1 (CAP_NET_BIND_SERVICE)
MAC        : confined to SELinux domain hardened_httpd_t

সবচেয়ে গুরুত্বপূর্ণ সারি শেষেরটা — একটা মাত্র SERVICE dict থেকে পাঁচটা স্বাধীন স্তরের প্রতিটাই একটা concrete, deploy-করার-উপযোগী config-এ রূপান্তরিত হয়েছে। Docker-এর নিজস্ব ১৪-capability ডিফল্ট থেকে মাত্র ১টা (NET_BIND_SERVICE) রেখে বাকি ১৩টা drop হয়েছে, আর SELinux stub স্পষ্টভাবে দেখাচ্ছে hardened_httpd_t-এর কোনো write-path hardened_httpd_content_t-এর দিকে নেই — মডিউলের Hardened HTTP Server প্রজেক্টে ঠিক এই প্যাটার্নটাই বাস্তব runc/Docker/Kubernetes config হিসেবে ব্যবহার করা হবে।

নিজে বাড়ান:

  1. SERVICE["syscalls_allowed"]-এ execve যোগ করে দেখুন seccomp profile-এ কী বদলায় — তারপর যুক্তি দিন কেন এটা কখনো একটা static-file server-এর জন্য করা উচিত না
  2. একটা দ্বিতীয় SERVICE dict বানান একটা এমন সার্ভিসের জন্য যার সত্যিই subprocess spawn করার দরকার আছে (যেমন একটা image-thumbnail generator যা ffmpeg কল করে) — allowlist-এ কী কী নতুন syscall/capability যোগ করতে হবে, আর কীভাবে সেই বর্ধিত অনুমতিকে যতটা সম্ভব সংকীর্ণ রাখবেন (যেমন শুধু নির্দিষ্ট একটা path-এর execve অনুমোদন, arg-constraint দিয়ে)
  3. build_selinux_rules-এ একটা নতুন rule যোগ করুন যা hardened_httpd_t-কে hardened_httpd_content_t-এ write করার অনুমতি দেয় শুধু একটা নির্দিষ্ট sub-domain (যেমন hardened_httpd_upload_t) থেকে — কেন এই ধরনের fine-grained domain-splitting বাস্তব SELinux policy-তে (যেমন Android-এর SEAndroid) সাধারণ একটা প্যাটার্ন তা নোট করুন

বাস্তব সিস্টেমে

বাস্তব জগতে — এই পাঁচটাই মিলে “hardened” শব্দটা বানায়

Docker-এর ডিফল্ট seccomp profile। প্রতিটা docker run ডিফল্টভাবে একটা built-in seccomp profile নিয়ে আসে যা প্রায় ৩০০+ Linux syscall-এর মধ্যে প্রায় ৪৪টা ব্লক করে — mount, reboot, init_module, keyctl, ptrace (কনটেইনার নিজের ভেতরেই), আর অন্যান্য কার্নেল-স্তরের বিপজ্জনক অপারেশন। বেশিরভাগ ব্যবহারকারী কখনো এটা লক্ষই করেন না — কারণ এটা প্রায় প্রতিটা স্বাভাবিক অ্যাপ্লিকেশনের জন্য স্বচ্ছ, অথচ পুরো একটা আক্রমণ-শ্রেণি নীরবে বন্ধ করে রাখে।

Kubernetes RuntimeDefault seccompProfile। Kubernetes 1.19+-এ securityContext.seccompProfile.type: RuntimeDefault সেট করলে container runtime (containerd/CRI-O)-র ডিফল্ট profile প্রয়োগ হয় — অনেক production cluster policy (Pod Security Standards-এর “restricted” স্তর) এটা বাধ্যতামূলক করে। এখানেই আজকের build অংশের JSON profile-এর বাস্তব প্রতিরূপ কাজে লাগে — একই defaultAction/syscalls কাঠামো।

systemd-এর SystemCallFilter=। কনটেইনার ছাড়াই, একটা সাধারণ systemd service-এ (nginx.service, sshd.service) SystemCallFilter=@system-service বা একটা কাস্টম allowlist লিখে দিলে systemd নিজেই সেই ইউনিটের জন্য একটা seccomp filter attach করে দেয় — কোনো container বা libseccomp কোড লেখা ছাড়াই একটা config লাইনে syscall filtering পাওয়া যায়।

SELinux — RHEL/Fedora/Android-এর ডিফল্ট। RHEL পরিবারে SELinux ডিফল্টভাবে Enforcing মোডে থাকে — httpd_t domain-এর একটা process /home-এর কোনো ফাইলে write করতে পারে না, DAC permission যা-ই বলুক, কারণ policy-তে সেই rule নেই (এটাই বহুল-পরিচিত “কেন আমার Apache config কাজ করছে না” সমস্যার মূল কারণ, httpd_sys_content_t label ছাড়া)। Android ২০১৩ সাল (SEAndroid) থেকে SELinux Enforcing মোডে চলে — প্রতিটা app একটা আলাদা, সংকীর্ণ domain-এ আবদ্ধ, যে কারণে একটা compromised app অন্য app-এর ডেটা সরাসরি পড়তে পারে না এমনকি একই Linux UID-স্তরের বাগ থাকলেও।

AppArmor — Ubuntu/Debian-এর বিকল্প MAC। SELinux-এর type/domain-ভিত্তিক মডেলের বদলে AppArmor path-ভিত্তিক — প্রতিটা profile সরাসরি ফাইল-পাথ উল্লেখ করে কী read/write/execute অনুমোদিত। কম নমনীয় কিন্তু শেখা সহজ — এই trade-off-ই Ubuntu/Debian পরিবার AppArmor বেছে নেওয়ার মূল কারণ, snap package sandboxing-এও এটাই ব্যবহৃত হয়।

যে ভুলগুলো সবাই করে

“একটা process namespace আর cgroup দিয়ে সম্পূর্ণ isolate করা থাকলে, সেটা যথেষ্ট সুরক্ষিত — আলাদা করে seccomp/capabilities/MAC দরকার নেই।”

আজকের intro আর concept সেকশনেই দেখানো হয়েছে — namespace আর cgroup কোনোটাই কোন syscall চালানো যাবে তা নিয়ন্ত্রণ করে না। একটা পুরোপুরি namespaced, cgroup-limited container-এর ভেতরেও একটা process execve("/bin/sh") চালাতে পারে, ptrace() দিয়ে sibling process-এর মেমরি পড়তে পারে, বা (যদি capability bounding set আলাদা করে সংকুচিত না থাকে) mount()/CAP_SYS_ADMIN-নির্ভর কৌশলে namespace boundary-র ফাঁক খুঁজে বের করতে পারে। namespace/cgroup “visibility” আর “quantity” নিয়ন্ত্রণ করে, “action” না — এই তিনটা আলাদা প্রশ্ন, তাই তিনটা আলাদা mechanism লাগে।

“seccomp চালু থাকলে মেমরি-করাপশন-ভিত্তিক আক্রমণও কার্যত অসম্ভব হয়ে যায়, কারণ 'বিপজ্জনক' syscall-গুলো তো ব্লকই।”

seccomp শুধু কোন syscall চেষ্টা করা যাবে তা নিয়ন্ত্রণ করে — কোন argument, কোন ডেটা, কোন উদ্দেশ্যে সেই syscall ব্যবহার হচ্ছে তা না (আজকের experiment-এ arg-constraint দেখানো হয়েছে ঠিকই, কিন্তু সেটা এখনও একটা সংকীর্ণ চেক, সম্পূর্ণ semantic বোঝা না)। Allowlist-এ থাকা read, write, mmap-এর মতো একেবারে সাধারণ syscall দিয়েই একজন attacker একটা memory-corruption bug কাজে লাগিয়ে ডেটা leak করতে পারে, একটা heap-spray চালাতে পারে, বা এমনকি existing memory-এর ভেতরেই (কোনো নতুন syscall ছাড়াই) ক্ষতিকর কাজ করতে পারে — আগের memory-safety লেসনগুলোর প্রায় প্রতিটা bug-ই allowlist-করা syscall-এর ভেতরেই ঘটে। seccomp আক্রমণের পরিসর সংকুচিত করে (বিশেষ করে post-exploitation pivoting, যেমন আজকের experiment-এর execve), সম্পূর্ণ memory-safety সমস্যা সমাধান করে না।

“একটা container-এ 'root' হিসেবে চলা মানেই সেই process পুরো host-এর ওপর root-এর ক্ষমতা রাখে, যদি না আলাদা করে user namespace UID remap করা থাকে।”

আগের লেসনের user namespace আলোচনায় দেখা গেছে UID mapping কীভাবে “namespace-এর ভেতরে root” আর “host-এর real root”-কে আলাদা করে — কিন্তু আজকের capabilities আলোচনা দেখায় এটা একমাত্র সুরক্ষা-স্তর না। Docker-এর মতো container runtime ডিফল্টভাবে user namespace remap ছাড়াই চলে (container-এর ভেতরের UID 0 আর host-এর UID 0 আসলে একই আসল UID), তবু Docker ডিফল্টভাবে capability bounding set ~১৪টায় নামিয়ে রাখে, CAP_SYS_ADMIN, CAP_SYS_MODULE, CAP_SYS_PTRACE-এর মতো শক্তিশালী bit বাদ দিয়ে। ফলে container-এর ভেতরের “root” এখনও অনেক kernel-level অপারেশন করতে পারে না — user namespace remapping না থাকলেও। “root ভেতরে” মানেই “root বাইরে না” — এটা user namespace, capability bounding set, seccomp, আর MAC — একসাথে চারটা স্বাধীন স্তরের ফলাফল, শুধু একটার না।

“SELinux/MAC আসলে file permission (chmod/chown)-এরই একটা বাড়তি, সূক্ষ্ম সংস্করণ — একই ধরনের জিনিস, শুধু বেশি অপশন।”

পার্থক্যটা মাত্রার (granularity) না, কর্তৃত্বের (authority)। DAC-এ একজন owner নিজের ফাইলের ওপর নিয়ন্ত্রণ রাখে — chmod 777 চাইলেই লেখা যায়, root প্রায় সবসময় override করতে পারে (CAP_DAC_OVERRIDE)। MAC-এ কোনো ব্যক্তি — ফাইলের owner হোক বা root — একতরফাভাবে নিয়ম বদলাতে পারে না; নিয়ম একটা কেন্দ্রীয়, আলাদাভাবে পরিচালিত policy-তে সংজ্ঞায়িত, আর policy বদলাতে হলে আলাদা administrative অ্যাক্সেস লাগে (SELinux policy compile/reload)। এই পার্থক্যের বাস্তব প্রভাব বিশাল — একটা compromised root process DAC-নির্ভর সিস্টেমে চাইলেই /etc/shadow পড়তে পারে (CAP_DAC_OVERRIDE তো root-এরই আছে), কিন্তু একটা properly-confined SELinux domain-এ সেই একই root process policy-তে অনুমতি না থাকলে /etc/shadow-ও পড়তে পারে না — diagram সেকশনের figure-এ ঠিক এই সিকোয়েন্সটাই দেখানো হয়েছে।

বুঝেছেন কি না দেখুন

1

namespace, cgroup, seccomp, capabilities, আর MAC — এই পাঁচটা স্তরের প্রতিটা ঠিক কোন একটা প্রশ্নের উত্তর দেয়?

স্মরণ

Namespace: এই process কী দেখতে পায়? Cgroup: কতটুকু resource ব্যবহার করতে পারে? seccomp: কোন syscall আদৌ চালাতে পারে? Capabilities: root-এর কোন নির্দিষ্ট ক্ষমতা তার আছে? MAC (SELinux): কোন object-এর ওপর কী করতে পারে, owner/DAC যা-ই বলুক। পাঁচটাই স্বাধীন প্রশ্ন, তাই একটা উত্তর দিলে বাকিগুলো স্বয়ংক্রিয়ভাবে উত্তর হয়ে যায় না।

2

Experiment সেকশনের attacker_trace-এ execve চেষ্টার পরের সব syscall “never reached” দেখাচ্ছে। এই আচরণ কেন seccomp-bpf-এর ডিজাইনের একটা সরাসরি, ইচ্ছাকৃত ফলাফল, শুধু একটা কোডিং accident না?

যুক্তি

seccomp-এর SECCOMP_RET_KILL_PROCESS verdict সাথে সাথে পুরো process-কে SIGKILL পাঠায় — এটা একটা exception বা recoverable error না, process নিজেই সেখানে বন্ধ হয়ে যায়। ফলে সেই process-এর instruction pointer কখনো পরের কোনো syscall-এর কাছে পৌঁছাতেই পারে না। এই কঠোরতা ইচ্ছাকৃত ডিজাইন-সিদ্ধান্ত: একটা allowlist-বহির্ভূত syscall মানে প্রায় নিশ্চিতভাবে হয় একটা bug নয়তো একটা আক্রমণ — সেই মুহূর্তে process চালিয়ে যাওয়ার চেয়ে অবিলম্বে বন্ধ করে দেওয়া নিরাপদ, ঠিক যেমন আগের লেসনের stack canary mismatch-এ ret execute হওয়ার আগেই abort() ডাকা হতো।

3

Capability bit-space হিসাব অনুযায়ী 2^41 টা সম্ভাব্য রাজ্য (state) তৈরি হয়, ঐতিহ্যগত root/non-root-এর মাত্র ২টা রাজ্যের তুলনায়। এই সংখ্যাগত পার্থক্য বাস্তবে একটা ping-জাতীয় ছোট utility-র ডিজাইনে ঠিক কী পরিবর্তন আনে?

প্রয়োগ

ঐতিহ্যগত মডেলে ping-কে raw ICMP socket খুলতে দিতে পুরো binary-কে SUID-root বানাতে হতো — একটা bug থাকলে পুরো root ক্ষমতাই হাতছাড়া হতো। Capability মডেলে শুধু CAP_NET_RAW bit-টা file-capability হিসেবে সেট করে দিলেই যথেষ্ট (setcap cap_net_raw+ep) — বাকি ৪০টা bit বন্ধ থাকে। একই bug থাকলেও attacker CAP_NET_RAW-এর বাইরে কোনো privileged অপারেশন করতে পারবে না — bug-এর “ব্লাস্ট রেডিয়াস” 2^41-ভাগের ১-এ নেমে আসে, পুরো root-ক্ষমতার বদলে একটামাত্র bit।

4

Diagram সেকশনের DAC/MAC figure-এ root user-এর জন্যও ধাপ ২ (MAC) আলাদা করে দেখানো হয়েছে, যদিও ধাপ ১ (DAC)-এ root প্রায় সবসময় পাস করে যায়। কেন এই দুইটা ধাপ আলাদা রাখা জরুরি — root DAC পাস করলেই কি MAC-চেক বাদ দেওয়া যেত না?

যুক্তি

না, কারণ দুইটা ধাপের কর্তৃত্বের উৎস আলাদা। DAC পাস হওয়ার অর্থ শুধু এটুকু যে ফাইলের নিজের permission bit/owner-এর হিসাবে অ্যাক্সেস অনুমোদিত (root-এর জন্য এটা প্রায় সবসময় true, কারণ root ঐতিহাসিকভাবে DAC override করতে পারে)। কিন্তু এই “root” দাবিটা নিজেই compromise হতে পারে — একটা exploited root process। MAC ধাপটা সেই সম্ভাবনার বিরুদ্ধে একটা স্বাধীন, দ্বিতীয় চেক — এমনকি সঠিক, বৈধ root process-ও যদি তার domain-এর policy-তে অনুমতি না থাকে, block হবে। দুইটা ধাপ মিলিয়েই “defense in depth” — যদি ধাপ ১ compromise হয় (root হাইজ্যাক), ধাপ ২ তখনও দাঁড়িয়ে থাকে, ধাপ ১-এর ব্যর্থতার ওপর নির্ভর না করে।

5

Build সেকশনের hardening_profile.py-এ যদি SERVICE["capabilities_needed"]-এ ভুলবশত CAP_SYS_ADMIN যোগ করা হতো, output-এর ঠিক কোন তিনটা জায়গায় এই ভুলের প্রভাব দেখা যেত, আর কেন প্রতিটাই একটা আলাদা সতর্কতা-সংকেত হতো?

প্রয়োগ

(১) dropped তালিকা থেকে CAP_SYS_ADMIN-এর সমতুল্য SYS_ADMIN বাদ পড়ে যেত (ধরে নিলে সেটা ALL_DOCKER_DEFAULT_CAPS-এ থাকত) — bounding set-এর আকার কমার বদলে বেড়ে যেত। (২) “combined” সারাংশে capabilities: bounding set reduced from 14 to 2 — সংখ্যাটা নিজেই সংকেত দিত least-privilege লক্ষ্য থেকে সরে গেছে। (৩) সবচেয়ে গুরুত্বপূর্ণ — এই একটা bit mount(), cgroup পুনর্গঠন, আর namespace-সংক্রান্ত প্রায় প্রতিটা admin অপারেশনের চাবি — একে “প্রয়োজনীয়” ঘোষণা করা মানে diagram সেকশনের পুরো LayerTrace-এর capabilities স্তরটাই কার্যত অকেজো করে দেওয়া, কারণ সেই স্তরের পুরো কাজ ছিল ঠিক এই bit-টা আটকানো।

6

একটা টিম একটা নতুন মাইক্রোসার্ভিস প্রোডাকশনে দিচ্ছে যেটা (ক) ইনকামিং ফাইল আপলোড গ্রহণ করে, (খ) সেগুলো একটা ইমেজ-প্রসেসিং লাইব্রেরি দিয়ে রূপান্তর করে, আর (গ) ফলাফল S3-জাতীয় object storage-এ আপলোড করে। আজকের পাঁচটা স্তর ব্যবহার করে একটা hardening plan ডিজাইন করুন — কোন স্তরে সবচেয়ে বেশি মনোযোগ দেবেন, আর কেন এই সার্ভিসটা আজকের static-file server-এর চেয়ে বেশি সতর্কতা দাবি করে?

ডিজাইন

এই সার্ভিস আজকের উদাহরণের চেয়ে ঝুঁকিপূর্ণ কারণ এটা untrusted, বাইনারি, জটিল-ফরম্যাটের ইনপুট (ইমেজ) নিজেই পার্স করে — ইমেজ-পার্সিং লাইব্রেরি ঐতিহাসিকভাবে memory-safety bug-এর ঘনঘন উৎস (আগের memory-safety লেসনগুলোর ঠিক এই ধরনের bug)। তাই: seccomp-এ আজকের static-file allowlist-এর চেয়ে সামান্য বড়, কিন্তু এখনও কঠোরভাবে সংকীর্ণ একটা তালিকা — execve এখনও বাদ (ইমেজ লাইব্রেরি নিজে subprocess spawn করার দরকার নেই যদি in-process library ব্যবহার হয়)। Capabilities: CAP_NET_BIND_SERVICE (ইনকামিং) ছাড়াও কিছুই লাগার কথা না — object storage আউটবাউন্ড HTTP, যার জন্য কোনো capability লাগে না। Namespace/cgroup: memory.max বিশেষভাবে গুরুত্বপূর্ণ, কারণ ইমেজ-ডিকোডিং bomb (decompression bomb) একটা পরিচিত DoS ভেক্টর — কড়া memory limit + pids.max দুটোই দরকার। MAC: ইমেজ-প্রসেসিং domain-কে filesystem-এ শুধু একটা নির্দিষ্ট temp-directory type-এ read/write অনুমতি দেওয়া উচিত, বাকি সব থেকে সম্পূর্ণ বিচ্ছিন্ন — parser bug থাকলেও local ফাইলসিস্টেমে ছড়িয়ে পড়তে না পারে। সংক্ষেপে: parsing স্তরটাই সবচেয়ে বেশি untrusted input হ্যান্ডল করে, তাই সেটাকেই সবচেয়ে সংকীর্ণ seccomp allowlist + সবচেয়ে কড়া MAC confinement-এর আওতায় আনা — attack-surface অনুযায়ী প্রতিটা স্তরের কড়াকড়ি ঠিক করা, সবখানে সমান কড়াকড়ি না।

এরপর কী

এরপর কী

আজ defense-in-depth-এর বাইরের বলয় সম্পূর্ণ হলো। আগের লেসনের namespace (visibility) আর cgroup (resource limit)-এর ওপর আজ তিনটা নতুন স্তর বসানো হলো — seccomp (কোন syscall চালানো যাবে), Linux capabilities (root-এর কোন নির্দিষ্ট bit থাকবে), আর SELinux-এর মতো Mandatory Access Control (কোন object-এ কী করা যাবে, root-কেও বেঁধে)। পাঁচটা স্তর মিলিয়েই আজকের production সিস্টেমে “hardened” শব্দটার আসল, বহু-স্তরীয় অর্থ — একটা মাত্র bypass কখনো পুরো সিস্টেম উন্মুক্ত করে দেয় না।

কিন্তু আজকের প্রতিটা স্তরই একটা নীরব অনুমানের ওপর দাঁড়িয়ে আছে: CPU নিজে বিশ্বস্তভাবে শুধু সেই instruction-গুলোই execute করবে যা আপনার policy অনুমোদন করেছে। seccomp allowlist বলে কোন syscall চলবে — কিন্তু ধরে নেয় CPU নিজে ঠিক সেই syscall-এর, ঠিক সেই process-এর জন্য নির্ধারিত মেমরি ছাড়া আর কিছু দেখছে না। Capabilities ঠিক করে কোন privileged অপারেশন অনুমোদিত — কিন্তু ধরে নেয় সেই checking নিজে কখনো ফাঁকি দেওয়া যায় না। MAC ধরে নেয় policy-তে যা লেখা নেই তা সত্যিই অদৃশ্য, শুধু “নিষিদ্ধ” না।

পরের লেসন ঠিক এই অনুমানটাই ভাঙবে — হার্ডওয়্যার স্তরে। Spectre আর Meltdown দেখায় আধুনিক CPU-র speculative execution আর branch prediction এমন ডেটা “স্পর্শ” করে যা কোনো software policy কখনো অনুমোদন করেনি — শুধু cache-এর timing পার্থক্য দিয়েই, কোনো allowlist-বহির্ভূত syscall ছাড়াই, কোনো capability ছাড়াই, কোনো MAC-violation ছাড়াই, একটা process অন্য process-এর (এমনকি অন্য namespace-এর, অন্য VM-এর) গোপন ডেটা পড়ে ফেলতে পারে। আজকের পাঁচটা স্তরের একটাও এই আক্রমণ সম্পর্কে কিছুই জানে না — কারণ বাগটা কোনো syscall-এ না, কোনো policy-তে না, বাগটা CPU নিজে কীভাবে দ্রুত হওয়ার চেষ্টা করে তার মধ্যে।

আরও পড়ুন

  • Seccomp BPF (SECure COMPuting with filters) — The Linux Kernel Documentation · seccomp-bpf-এর প্রামাণ্য kernel documentation — BPF প্রোগ্রাম, seccomp_data struct, আর SCMP_ACT_* action-গুলোর নির্ভুল semantics এখানেই সংজ্ঞায়িত
  • capabilities(7) — overview of Linux capabilities · ৪১টা capability-র সম্পূর্ণ তালিকা, আর permitted/effective/inheritable/bounding/ambient সেটের নির্ভুল সংজ্ঞা
  • SELinux Project — documentation wiki · SELinux-এর type enforcement, security context, আর policy language-এর হালনাগাদ, প্রামাণ্য দলিল
  • Integrating Flexible Support for Security Policies into the Linux Operating System — Peter Loscocco, Stephen Smalley — USENIX Annual Technical Conference (FREENIX Track), 2001 · SELinux-এর প্রতিষ্ঠাতা পেপার — NSA-র মূল ডিজাইন-দলিল, যেখানে প্রথম দেখানো হয় কীভাবে একটা flexible Mandatory Access Control architecture একটা mainstream OS kernel-এ বসানো যায়