Identitas container kini diturunkan, tetapi rumus Chapter Prinsip Keamanan Container masih menyimpan suku yang lebih besar: sekalipun berjalan sebagai user biasa — atau justru masih sebagai root — kekuatan yang melekat pada proses itu sendiri belum kita sentuh. Sejak kernel Linux 2.2 dirilis akhir 1990-an, privilege root yang dulu monolitis dipecah menjadi unit-unit kecil bernama capability, dan Docker sejak awal memanfaatkan pecahan itu: container bawaan hanya menerima dua belas dari sekitar tiga puluh delapan capability yang dimiliki root host. Chapter ini membedah peta dua belas capability itu, membuktikan mana yang absen lewat perintah yang gagal, lalu mematangkan pola produksi yang paling dianjurkan: cabut semuanya dengan --cap-drop=ALL, lalu tambahkan kembali hanya yang benar-benar dibutuhkan aplikasi — satu per satu, dituntun oleh pesan galat.

Root yang Dipecah Menjadi Capabilities #

Sistem Unix klasik mengenal dua kasta: root yang boleh segalanya, dan user biasa yang dibatasi izin berkas. Model dua kasta itu kasar untuk kebutuhan modern — daemon pencatat log misalnya hanya butuh hak mengikat port rendah, bukan hak memformat disk. Kernel Linux 2.2 karenanya memecah kekuatan root menjadi unit-unit bernama capability: CHOWN untuk mengubah kepemilikan berkas, NET_BIND_SERVICE untuk mengikat port di bawah 1024, SYS_TIME untuk mengubah jam sistem, dan seterusnya hingga sekitar tiga puluh delapan unit pada kernel masa kini. Sebuah proses membawa kumpulan capability dalam atributnya, dan kernel memeriksa capability itu setiap kali syscall sensitif dipanggil — terlepas dari apakah proses berjalan sebagai UID 0 atau bukan.

Pemecahan inilah yang menjelaskan satu kejutan dari chapter sebelumnya: container non-root yang kebetulan masih bisa mengikat port 80, karena NET_BIND_SERVICE melekat pada proses container bawaan apa pun identitas user-nya. Dua sumbu — identitas (UID) dan capability — bekerja independen, dan keamanan yang matang mengatur keduanya.

Dua Belas Capability Bawaan Container #

Docker memberikan dua belas capability ini kepada setiap container bawaan:

CapabilityDaya yang diberikan
CHOWNmengubah kepemilikan berkas dengan chown
DAC_OVERRIDEmelewati pemeriksaan izin baca-tulis-eksekusi berkas
FOWNERmengubah izin berkas yang bukan milik proses
FSETIDmenyetel bit setuid dan setgid pada berkas
KILLmengirim sinyal ke proses milik user lain
MKNODmembuat berkas device khusus dengan mknod
NET_BIND_SERVICEmengikat port di bawah 1024
SETFCAPmenyetel capability pada berkas
SETGIDmengubah GID proses
SETPCAPmenambah capability dalam batas yang diizinkan
SETUIDmengubah UID proses
AUDIT_WRITEmenulis catatan audit ke kernel

Yang Tidak Ada di Daftar #

Jauh lebih penting dari yang ada adalah yang tidak ada. Root host memegang NET_ADMIN untuk menata antarmuka jaringan dan routing; root container tidak. Root host memegang NET_RAW untuk membuat socket paket mentah — alat kerja tcpdump dan tool jaringan tingkat rendah lainnya; root container tidak. Root host memegang SYS_ADMIN yang membukakan mount, pembuatan namespace, dan puluhan operasi administratif lain; root container tidak. Root host bisa mengubah jam sistem lewat SYS_TIME dan memuat modul kernel lewat SYS_MODULE; root container tidak bisa keduanya. Kesimpulannya tegas: root di dalam container tetap lebih lemah daripada root di host — bukan karena nama root-nya berbeda, melainkan karena daftar capability yang dibawanya jauh lebih pendek.

Mencoba Capability yang Absen #

Teori di atas bisa dibuktikan dengan satu perintah jam. Container ubuntu mencoba mengubah tanggal sistemnya:

bash
$ docker run --rm ubuntu:24.04 date -s "2026-01-01"

Output yang Dihasilkan:

text
date: cannot set date: Operation not permitted

Mari kita telaah penolakan tersebut:

  1. Operation not permitted adalah kode EPERM dari kernel — kernel menolak syscall pengubah jam karena SYS_TIME tidak dibawa proses ini; Docker memang tidak pernah memberikannya ke container bawaan.
  2. Jam adalah milik kernel, dan kernelnya satu — container tidak punya kernel sendiri, sehingga tidak ada “jam container” yang bisa digeser terpisah dari host; berbeda dari mesin virtual yang membawa kernel dan jam sendiri di dalam guest.
  3. Pesan galat tidak pernah menyebut capability apa yang absen — diagnosa semacam ini menuntut peta seperti tabel di atas; mengenali pola “EPERM pada operasi administratif” adalah keterampilan inti section ini.

Mencabut yang Ada #

Sisi sebaliknya juga layak dibuktikan: capability yang memang dibawa container bisa kita cabut dengan flag --cap-drop. Pasangan berikut membandingkan chown sebelum dan sesudah seluruh capability dicabut:

bash
$ docker run --rm ubuntu:24.04 sh -c "touch /uji && chown 1000:1000 /uji && echo chown-sukses"

Output yang Dihasilkan:

text
chown-sukses
bash
$ docker run --rm --cap-drop=ALL ubuntu:24.04 sh -c "touch /uji && chown 1000:1000 /uji && echo chown-sukses"

Output yang Dihasilkan:

text
chown: changing ownership of '/uji': Operation not permitted

Mari kita telaah pasangan tersebut:

  1. chown berhasil pada container bawaan — CHOWN ada di daftar dua belas; proses root container berhak mengubah kepemilikan berkasnya.
  2. --cap-drop=ALL melucuti seluruhnya — dengan daftar capability kosong, kernel menolak chown meski proses berjalan sebagai UID 0; capability diperiksa terpisah dari identitas user.
  3. touch tetap berhasil pada keduanya — membuat berkas di direktori / yang dimiliki root hanya butuh hak pemilik direktori, bukan capability tertentu; pencabutan capability tidak melumpuhkan operasi berkas yang wajar.

Pola Produksi: Buang Semua, Tambah Seperlunya #

Dua belas capability bawaan adalah tebakan kompromi yang enak untuk mayoritas aplikasi — tetapi kebijakan keamanan yang serius tidak bertumpu pada tebakan. Pola yang dianjurkan bekerja seperti daftar izin (allowlist): mulai dari nol hak, lalu tambahkan hanya yang terbukti dibutuhkan. Kita peragakan siklusnya dengan server berkas kecil milik Python, dari titik paling ketat:

bash
$ docker run -d --name berkas-publik --cap-drop=ALL -p 8084:8080 \
    python:3.12-slim python -m http.server 8080

Output yang Dihasilkan:

text
9a3c7e1b5d2f4a6c8e0b2d9f1a4c6e8b3d5f7a9c1e3b5d7f2a4c6e8b0d1f3a5c
bash
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8084

Output yang Dihasilkan:

text
200

Aplikasi yang hanya membaca berkas dan mendengarkan port tinggi tidak merasakan satu capability pun hilang. Sekarang kita pindahkan layanan yang sama ke port 80 — port rendah yang dilindungi kernel:

bash
$ docker rm -f berkas-publik
$ docker run -d --name berkas-publik --cap-drop=ALL -p 8084:80 \
    python:3.12-slim python -m http.server 80

Output yang Dihasilkan:

text
8c2e9a4f1b7d3c6e5a8f0b2d4c6e8a1f3b5d7c9e0a2f4b6d8c1e3a5f7b9d0c2e
bash
$ docker ps -a --filter name=berkas-publik

Output yang Dihasilkan:

text
CONTAINER ID   IMAGE             COMMAND                  CREATED         STATUS                      PORTS     NAMES
8c2e9a4f1b7d   python:3.12-slim  "python -m http.serve…"  9 seconds ago   Exited (1) 4 seconds ago             berkas-publik
bash
$ docker logs berkas-publik

Output yang Dihasilkan:

text
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  ...
  File "/usr/local/lib/python3.12/socketserver.py", line 305, in server_bind
    socket.bind(self.server_address)
OSError: [Errno 13] Permission denied

Log error inilah kompas kita. Kita tambahkan satu capability yang ditunjuknya, lalu ulangi:

bash
$ docker rm -f berkas-publik
$ docker run -d --name berkas-publik --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
    -p 8084:80 python:3.12-slim python -m http.server 80

Output yang Dihasilkan:

text
6f1d8a3c9b2e5f7a0d4c6e8b1f3a5c7e9d0b2f4a6c8e0d2b5f7a3c1e9d4b6f8a
bash
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8084

Output yang Dihasilkan:

text
200

Mari kita telaah siklus tiga langkah di atas:

  1. Mulai dari titik paling ketat — --cap-drop=ALL pada langkah pertama; aplikasi yang hanya butuh berkas dan port tinggi langsung terbukti hidup tanpa satu capability pun.
  2. Baca galat, jangan menebak — traceback menunjuk OSError errno 13 tepat saat socket.bind pada port 80; galat izin saat mengikat port rendah adalah tanda tangan NET_BIND_SERVICE yang absen, dan pencarian singkat atas pesan itu mengarah ke capability yang sama.
  3. Tambahkan persis satu capability yang ditunjuk, lalu ulangi — container kini membawa satu capability, bukan dua belas; setiap hak yang melekat pada proses adalah hak yang bisa dipertanggungjawabkan.

Keadaan akhir container bisa diverifikasi dari luar tanpa membuka shell. docker inspect dengan format template membaca dua field konfigurasi host:

bash
$ docker inspect --format '{{.HostConfig.CapDrop}} {{.HostConfig.CapAdd}}' berkas-publik

Output yang Dihasilkan:

text
[ALL] [NET_BIND_SERVICE]

Output itu adalah dua slice Go yang dicetak template: [ALL] — seluruh capability dicabut — dan [NET_BIND_SERVICE] — satu-satunya yang ditambahkan kembali. Beres untuk container latihan; jangan lupa membersihkannya dengan docker rm -f berkas-publik.

Use Case Nyata dari Lapangan #

Dua kasus nyata menunjukkan kisarannya. Pertama, nginx klasik: master process-nya berjalan sebagai root dan mendengarkan port 80, sehingga butuh NET_BIND_SERVICE; ia juga menurunkan worker ke UID 101 — pekerjaan yang menuntut SETUID dan SETGID; dan entry point-nya membereskan direktori cache saat menyala, yang menuntut CHOWN, DAC_OVERRIDE, dan FOWNER. Daftar yang “seperperlunya” untuk nginx sungguhan karenanya cukup panjang — dan itu kabar jujur yang perlu disampaikan: pola drop-ALL menuntut kesabaran untuk image yang dirancang era kebebasan root. Dua jalan pragmatis menyambut: pakai varian nginx-unprivileged yang mendengarkan 8080 sejak lahir sehingga nyaris tak butuh apa pun, atau biarkan container mendengarkan 8080 dan terbitkan -p 80:8080 seperti pola favorit Chapter Menjalankan Container Non-Root.

Kedua, penyadapan lalu lintas dengan tcpdump — image netshoot kerap kita pakai untuk diagnosa jaringan. Tanpa capability tambahan, tcpdump langsung tersandung:

bash
$ docker run --rm nicolaka/netshoot tcpdump -c 1 -i eth0

Output yang Dihasilkan:

text
tcpdump: socket: Operation not permitted

Socket paket mentah menuntut NET_RAW, dan mode promiscuous menuntut NET_ADMIN — dua-duanya di luar daftar bawaan. Ditambah --net=host supaya melihat interface host dan bukan sekadar veth container, kombinasi lengkapnya berbunyi:

bash
$ docker run --rm --net=host --cap-add=NET_RAW --cap-add=NET_ADMIN \
    nicolaka/netshoot tcpdump -c 2 -i any

Output yang Dihasilkan:

text
tcpdump: data link type PKTAP
tcpdump: verbose output suppressed, use -v[v...] for full protocol decode
listening on any, link-type PKTAP (Linux Cooked v1), snapshot length 262144 bytes
...
2 packets captured
2 packets received by filter
0 packets dropped by kernel

Kasus ini sekaligus contoh sehat pemberian capability: dua hak spesifik diberikan untuk satu pekerjaan diagnosa, container sekali pakai, lalu lenyap — bukan memberikan --privileged hanya demi satu tool.

Capability yang Harus Dicurigai #

Menambah capability kadang memang perlu; lima nama berikut layak melebihi sekadar dicurigai:

CapabilityDaya yang dibukaKenapa berbahaya
SYS_ADMINmount, pembuatan namespace, dan puluhan operasi administratif lainsetara root di host — pintu utama breakout
NET_ADMINmenata antarmuka, routing, dan aturan firewallbisa membongkar jaringan host dan container tetangga
SYS_MODULEmemuat modul kernel ke dalam kernel yang berjalanmodul jahat berarti kode menempel di dalam kernel host
SYS_PTRACEmengintip dan menyuntik proses lainbisa membaca memori proses host dan mencuri kredensialnya
NET_RAWmembuat socket paket mentahpenyadapan dan pemalsuan lalu lintas jaringan

Capabilities di Compose #

Seluruh flag docker run ini punya padanan first-class di compose.yaml, sehingga pola drop-ALL bisa menjadi konfigurasi tetap stack dan ikut versioned bersama kode:

yaml
services:
  berkas-publik:
    image: python:3.12-slim
    command: python -m http.server 80
    ports:
      - "8084:80"
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE

Mari kita telaah pemetaannya:

  1. cap_drop: - ALL — padanan --cap-drop=ALL; daftar di Compose juga menerima nama capability spesifik bila hanya sebagian yang dicabut.
  2. cap_add: - NET_BIND_SERVICE — padanan --cap-add; daftar ini hanya menambahkan kembali apa yang memang dibutuhkan service bersangkutan.
  3. command menimpa CMD image — baris ini menyetel aplikasi ke port 80 di dalam container, dan ports menerbitkannya sebagai 8084 di host; pola port tinggi-versus-pemetaan dari chapter sebelumnya tetap berlaku berdampingan dengan capability.

Kita kini mengendalikan dua sumbu sekaligus: identitas proses dari chapter sebelumnya, dan daftar privilege yang dibawanya di chapter ini. Tetapi ada satu bendera yang sengaja kita simpan terakhir — bendera yang membalik seluruh kerja keras ini dalam satu kata: --privileged memberikan semua capability sekaligus, membukakan seluruh device host, dan mematikan seccomp beserta LSM dalam satu tarikan napas. Chapter Privileged Container membongkar apa saja yang sebenarnya dilepasnya, membuktikan bahayanya lewat disk host yang tiba-tiba terlihat, lalu memetakan lima alternatif yang lebih aman untuk setiap alasan klasik pemakaian --privileged.

Referensi #