Artikel mendalam tentang teknologi yang membentuk masa depan.

Satu Asterisk, Sepuluh Tahun: Bug SSH Alpine Linux

Bug SSH di Alpine Linux membiarkan password apa pun di atas 256 karakter masuk ke akun terkunci selama sepuluh tahun. Begini penyebab quirk musl crypt() itu.

Pintu brankas dengan lubang berbentuk asterisk yang bersinar di ruang server gelap
Satu karakter di file sistem jadi pembeda antara akun terkunci dan pintu yang terbuka.

Ini angka yang seharusnya bikin kamu merinding: 257. Selama sekitar sepuluh tahun, mengetik password apa pun dengan 257 karakter atau lebih di prompt SSH pada jenis sistem Linux yang salah bukannya ditolak, malah diloloskan. Password apa pun. Ketikan acak di keyboard, dump base64 foto liburanmu, setengah pesan Slack yang tidak sengaja ter-paste: selama cukup panjang, pintunya terbuka. Bug SSH Alpine Linux yang dilaporkan dengan nama "sshole" ini termasuk kerentanan yang kelihatannya seperti lelucon sampai kamu cek kondisinya, dan saat itu juga terasa seperti laporan insiden.

Intinya begini: pada OpenSSH yang di-build dengan musl libc, yaitu Alpine, OpenWrt, Buildroot, Yocto, dan sebagian besar Docker base image di internet, akun yang seharusnya mustahil secara matematis untuk dimasuki justru menerima password apa pun yang panjangnya 257 sampai 1.024 karakter. Root. User deploy yang hanya pakai kunci. User default alpine di image resmi Alpine untuk AWS dan Azure, yang datang dengan doas tanpa password. Perbaikannya ada di OpenSSH 10.6p2. Tapi cerita bagaimana ini terjadi adalah rangkaian keputusan kecil yang masuk akal sejak 1980, dan setiap mata rantainya kelihatan benar saat dibuat.

Cara Kerja Bug SSH Alpine Linux

Untuk memahami tabrakannya, kamu butuh tiga komponen, masing-masing dimiliki codebase dan dekade yang berbeda. Pertama: bagaimana Linux menyimpan password. Saat kamu mengatur password, hash-nya masuk ke /etc/shadow, satu baris per akun. Baris untuk akun biasa terlihat seperti $6$salt$hash, di mana $6$ berarti sha512crypt. Tapi akun yang seharusnya tidak pernah menerima password, seperti daemon, user yang hanya pakai kunci, dan root yang terkunci, mendapat placeholder:

alice:$6$yJ3kXw9q$k2P9vLm4...:19723:0:99999:7:::
deploy:*:19723:0:99999:7:::
root:*:19723:0:99999:7:::

Saat kamu login, sshd mengambil apa yang kamu ketik, di-hash dengan salt yang tersimpan, lalu membandingkan string hasilnya dengan field yang tersimpan. String sama, kamu masuk. Tanda asterisk adalah sentinel: ia bukan hash dari apa pun, jadi tidak ada password yang bisa cocok dengannya. Ini sudah jadi mekanisme penguncian untuk akun yang hanya pakai kunci sejak awal era Unix. Mekanisme ini bekerja karena perbandingannya tidak pernah bisa berhasil, kecuali fungsi hashing-nya sendiri bisa dibuat menghasilkan asterisk. Yang membawa kita ke komponen kedua.

Komponen kedua adalah penanganan error di musl. Secara tradisional, crypt() mengembalikan NULL saat tidak bisa menjalankan tugasnya, misalnya salt meminta algoritma yang tidak tersedia di sistem. Masalahnya, bertahun-tahun software tidak pernah mengecek NULL dan dengan santai meneruskannya ke strcmp sehingga segfault. Jadi di 2012, musl membuat pilihan desain yang disengaja: crypt()-nya tidak akan pernah mengembalikan NULL. Saat gagal, ia mengembalikan string yang dijamin tidak cocok dengan hash password asli mana pun, supaya pemanggil yang rusak melihat password salah alih-alih crash. String kegagalan yang mereka pilih adalah *. Tiga minggu kemudian musl menambahkan sha512crypt dengan batas input 256 byte, karena biaya hashing berbanding lurus dengan panjang password dan password raksasa adalah vektor denial-of-service. Tolak yang lebih panjang, itu dianggap gagal, dan gagal berarti mengembalikan *. Komentar yang mendokumentasikan hal ini ada di source code sepanjang waktu.

Komponen ketiga adalah sshd yang bersikap sopan. Di 2016, CVE-2016-6210 menunjukkan kamu bisa mengenumerasi username valid lewat timing respons: user yang tidak ada langsung ditolak karena tidak ada hash untuk dihitung. Perbaikannya elegan. Untuk akun tanpa hash yang bisa dipakai, sshd meminjam salt dari akun lain dan tetap meng-hash password yang diberikan, semata-mata untuk menghabiskan waktu yang sama. Penolakan yang lebih lambat. Masalahnya: jalur ini tidak hanya terpicu untuk user yang tidak ada. Ia juga terpicu untuk user sungguhan yang field shadow-nya *, karena asterisk bukan hash yang bisa dipakai. Jadi sekarang sshd menghitung crypt(password_kamu, salt_pinjaman) dan membandingkan hasilnya dengan *.

/* what sshd effectively does in this configuration */
const char *stored = shadow_field(user);        /* "*" for locked accounts */
const char *salt   = pick_salt(user);           /* borrowed: "*" isn't a valid hash */
char *ret = crypt(typed_password, salt);        /* musl: "*" if password > 256 bytes */
if (ret && strcmp(ret, stored) == 0)
allow_login();                              /* "*" == "*"  →  welcome in */

Setiap elemen harus selaras: field yang tersimpan adalah *, crypt gagal, dan nilai kegagalannya juga *. Di glibc, crypt yang gagal mengembalikan NULL, yang membuat perbandingan gagal dengan aman. Di sistem libxcrypt, string kegagalannya adalah *0. Beda satu karakter, dan satu karakter itu adalah seluruh permainannya, karena *0 tidak pernah sama dengan *. Di musl, nilai error dan nilai kunci adalah karakter yang sama, dan strcmp tidak bisa membedakan error dari kecocokan. Dua makna, satu saluran, dan penerimanya tidak bisa membedakan keduanya.

Satu Dekade Keputusan yang Masuk Akal

Yang membuat bug ini layak dipelajari bukan mekanismenya, melainkan fakta bahwa tidak ada yang terlibat melakukan hal yang jelas-jelas bodoh. Mulai dari 1980: saat hash password masih ada di /etc/passwd yang bisa dibaca semua orang, Bill Joy membuat akun bernama daemon yang tidak boleh pernah dimasuki siapa pun, dan menaruh * di tempat hash password-nya. Sentinel yang bersih, puluhan tahun lebih awal dari zamannya. (Kolom nama lengkapnya konon bertuliskan "the devil himself.") Unix masih mengunci akun persis dengan cara ini.

Keputusan musl di 2012 juga terlihat bisa dipertahankan dari setiap sudut. Caller yang crash adalah masalah nyata, dan pustaka C yang menjadikan NULL senjata melawan kode aplikasi yang ceroboh tentu bikin banyak musuh. Mengembalikan string yang tidak akan pernah cocok adalah degradasi yang anggun. Begitu juga membatasi panjang password untuk menghentikan denial-of-service berbasis hash. Begitu juga perbaikan timing sshd, karena memberi padding pada respons untuk user yang tidak bisa di-hash adalah praktik standar. Bahkan apa yang dilakukan Alpine setelahnya ikut masuk akal. Di 2019, CVE-2019-5021 mengungkap bahwa image Docker Alpine dikirim dengan field password root yang kosong, di beberapa konfigurasi tanpa password sama sekali. Alpine lalu mengunci root dengan !. Tapi tanpa PAM, sshd memperlakukan ! sebagai terkunci sepenuhnya, yang juga mematikan login SSH pakai kunci, dan image cloud hidup dan mati dengan login kunci. Jadi di 2022, Alpine 3.16 memindahkan root ke *: tetap terkunci, kunci tetap bisa dipakai, semua senang. Kecuali * adalah string yang persis bisa dipancing crypt musl untuk dicetak.

Setiap langkah adalah respons yang bisa dipertahankan terhadap masalah nyata. Itulah yang bikin tidak nyaman: kamu bisa saja ada di ruangan saat setiap keputusan dibuat dan ikut mengangguk.

Nyaris-kena-nya malah lebih parah. Seorang developer Debian pernah meminta glibc mengadopsi perilaku * persis seperti musl; enam tahun kemudian Debian pindah ke libxcrypt, yang *0-nya menutup lubang itu secara kebetulan. Sebuah thread BusyBox mengeluhkan passwd yang tersendat di password 257 karakter: asterisk yang sama, batas yang sama, dan tidak ada yang di thread itu menyebut "sshd." Seorang security reviewer menambal fungsi pick_salt() di sshd dengan bug aslinya berada empat baris di bawahnya, empat hari sebelum akhirnya ada yang menemukannya. Hal ini terlewat berulang kali oleh orang-orang yang hampir menatapnya langsung.

Domino dari berbagai bahan jatuh berurutan di atas meja gelap
Empat dekade keputusan kecil yang masuk akal, masing-masing menjatuhkan yang berikutnya.

Apakah Infrastruktur Kamu Terdampak Bug OpenSSH musl?

Kabar baiknya dari sshole adalah ia membutuhkan kombinasi lengkap kondisi sebelum bisa dieksploitasi. Lewatkan satu saja dan kamu aman. Kamu rentan jika semua kondisi ini benar:

  • sshd kamu di-build dengan musl: Alpine, OpenWrt, Buildroot, Yocto, atau image Docker turunan alpine dengan openssh-server terpasang.
  • sshd tidak melewati PAM. openssh-server Alpine tidak melakukannya secara default, atau kamu mengatur UsePAM no. Jalur kode PAM tidak pernah memanggil crypt untuk akun *.
  • Autentikasi password diaktifkan. Secara default memang aktif.
  • Setidaknya satu akun memiliki * polos di /etc/shadow: user yang hanya pakai kunci, akun berbasis LDAP, root di Alpine.
  • Setidaknya satu akun lain memiliki hash password sungguhan, sehingga ada salt untuk dipinjam.

Kalau kelima kondisi terpenuhi, setiap akun * di server itu menerima password apa pun dengan panjang 257 sampai 1.024 karakter. Konfigurasi paling mengerikan adalah image resmi Alpine untuk AWS dan Azure: user default alpine hanya pakai kunci (jadi *) dan punya doas tanpa password. Login dengan password panjang di sana bukan eskalasi hak akses, itu pintu depan langsung ke root. Satu mitigasi layak diapresiasi karena membuat ini tetap tenang selama itu: PermitRootLogin di OpenSSH sudah menolak login password untuk root sejak 2015, jadi target paling jelas sebagian terlindungi secara default. Tidak ada yang melindungi user deploy dan akun service.

Kalau kamu pakai Debian, Ubuntu, Fedora, atau apa pun yang berbasis glibc, kamu aman. Yang lewat PAM: aman. Dropbear: aman. OpenBSD: aman. Satu catatan untuk para skeptis: upaya reproduksi dari komunitas awal tidak konsisten, karena kelima kondisi itu mudah terlewat satu per satu. Ada yang mencoba image Alpine resmi, tidak sadar field root adalah ! bukan *, gagal, lalu pergi. Anggap "tidak bisa mereproduksi di laptop saya" sama sekali bukan bukti untuk production. Cek kondisinya, bukan feeling-nya.

Akar Masalah Sebenarnya: In-Band Signaling dan Magic String

Godaannya adalah menaruh sshole di kotak "edge case libc yang aneh" lalu lanjut. Mode kegagalannya lebih tua dan lebih umum dari itu: in-band signaling. Karakter * dipakai untuk dua pekerjaan. Di /etc/shadow artinya "tidak ada password untuk akun ini," dan di crypt musl artinya "hashing gagal." Dua pesan berbagi satu saluran, tanpa cara bagi penerima untuk membedakannya. Nilai sentinel yang tertanam di data biasa adalah cara sistem akhirnya mengeksploitasi dirinya sendiri. NULL adalah sinyal error yang bisa diterima (meski caller yang rawan crash jadi masalah). Exception lebih baik. Result<T, E> yang memaksa caller menangani kegagalan secara eksplisit adalah yang terbaik. Aturan praktisnya, yang akan saya tato di tangan setiap programmer sistem: jangan pakai magic string. Kalau kamu benar-benar harus punya satu, pilih sesuatu yang tidak mungkin bertabrakan dengan nilai apa pun di domain masalah. *0 dari libxcrypt mengalahkan * milik musl tepat satu byte, dan satu byte itu adalah pembeda antara quirk dan CVE.

Ada pelajaran kedua tentang shim kompatibilitas yang diam-diam. Keputusan * yang anggun di musl ada untuk mencegah software rusak crash, yang berarti ia juga mencegah software rusak disadari sebagai rusak. Segfault memang jelek, tapi ia juga sinyal: seseorang melaporkan bug, seseorang memperbaiki caller-nya. Telan error-nya dan kesalahan itu menumpuk diam-diam selama sepuluh tahun. Saya makin yakin ini adalah pemisah paling dalam dalam desain sistem: engineering yang menahan bencana cenderung gagal dengan keras dan dini, sementara engineering yang mengoptimalkan kemulusan menumpuk utang tersembunyi sampai tagihannya datang sekaligus.

Dan ini seharusnya mematahkan mitos yang menenangkan: bahwa kode lama yang banyak dipakai lebih bisa dipercaya karena sudah teruji. Usia tidak mendistribusikan pengawasan secara merata. Ia memusatkan pengawasan di jalur panas dan membiarkan jalur dingin membatu. Semua orang mengaudit kriptografi. Hampir tidak ada yang mengaudit lem membosankan tempat sshd berbicara dengan libc soal password, karena kode itu "sudah jalan selamanya." Irisan dua konfigurasi minoritas, yaitu sistem musl dan di dalamnya yang tidak memakai PAM, adalah tempat persis di mana bug bisa bersembunyi tanpa terganggu selama sepuluh tahun.

Cara Memperbaiki Bug sshole dan Mengecek Log Kamu

Patch-nya bagian yang mudah: OpenSSH 10.6p2 menutup lubang ini. Di Alpine, cukup satu perintah. Kalau kamu belum bisa update hari ini, mitigasinya sama blak-blakan dan sepenuhnya efektif: matikan autentikasi password. Tidak ada pengecekan password, tidak ada bug. Terus terang, sebaiknya kamu lakukan keduanya, karena autentikasi hanya-kunci memang jawaban yang benar bahkan sebelum bug ini ada dan akan tetap begitu setelah semua orang patch. Ini mirip pelajaran dari debat patch-versus-mitigasi lainnya: saat mitigasinya murah dan total, jangan jadi pintar sendiri.

# patch: openssh 10.6p2 or later
apk upgrade openssh
# can't patch yet? passwords off, keys only
echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.conf
rc-service sshd restart
Lentera menerangi cetakan log yang berjajar di tengah kegelapan, satu baris menyala merah
Di antara sepuluh tahun log autentikasi, password yang sangat panjang mungkin pernah membuka akun yang terkunci.

Lalu pertanyaan yang lebih sulit: apakah ada yang sudah lebih dulu sampai ke sana? Sepuluh tahun itu waktu yang sangat lama. Yang perlu kamu cari adalah autentikasi password yang berhasil untuk akun yang tidak pernah punya password: user deploy yang hanya pakai kunci, akun service, identitas LDAP, root di server Alpine. Login sukses untuk akun-akun itu lewat metode password seharusnya sangat jarang; setiap temuan adalah kebakaran level lima alarm. Kenyataan yang tidak nyaman: sshd mencatat keberhasilannya, bukan panjang password, jadi banyak bukti langsung lenyap saat ditulis. Dan seperti yang sudah kita bahas, logging autentikasi cloud punya titik buta bahkan di hari yang baik. Log sshd di VM berumur pendek adalah tempat persisnya ia berada. Grep apa yang kamu punya. Anggap itu belum lengkap.

Apa yang Seharusnya Diajarkan Sepuluh Tahun yang Sunyi

Penemuannya sendiri layak mendapat perhatian sebanyak bug-nya. Peneliti yang menemukan sshole tidak menatap kriptografinya. Mereka menatap batas membosankan tempat OpenSSH berbicara dengan libc, membangun sshd dalam setiap konfigurasi yang terpikir (glibc, musl, PAM, tanpa PAM, shadow, tanpa shadow), lalu melempar sekitar 17.600 login yang rusak ke matriks itu. Hash aneh, akun rusak, password yang terlalu panjang. Setiap build sepakat untuk setiap login kecuali satu: musl tanpa PAM. Yang satu itu bilang ya untuk hal-hal yang seharusnya tidak. Ini bukan penemuan keberuntungan; ini soal coverage. Differential testing di seluruh matriks konfigurasi adalah cara menemukan bug yang bersembunyi di irisan, dan ini tepat jenis pekerjaan melelahkan dan menyeluruh yang ternyata sangat bagus dikerjakan oleh swarm agent. Sshole berikutnya tidak akan mendapat sepuluh tahun yang sunyi.

Pertanyaan terbuka yang seharusnya bikin operator tidak tidur adalah apakah sepuluh tahun itu benar-benar berlalu tanpa ada yang sadar, atau apakah orang yang salah sudah sadar. Pertimbangkan kasus yang tidak disengaja saja: paste isi clipboard yang salah ke prompt password, entah itu setengah thread Slack, seluruh private key SSH, atau apa pun yang lebih dari 257 karakter. Di server yang rentan, akun yang terkunci begitu saja terbuka. Kalau kamu tidak pernah salah paste ke kolom password, kamu lebih hati-hati daripada siapa pun yang pernah saya kerjakan bersama. Seseorang, di suatu tempat, pernah login ke server yang tidak mereka maksud dan diam-diam menutup terminal. Mungkin banyak orang.

Jadi patch, ya. Matikan password, pasti. Tapi lalu luangkan satu jam dan cari di sistemmu sendiri karakter yang menjalankan dua pekerjaan: nilai sentinel, fallback diam-diam, cabang "ini tidak mungkin terjadi", shim kompatibilitas yang tidak ada yang ingat pernah menulisnya. Setiap codebase yang matang punya semuanya. Mereka tak terlihat justru karena tidak pernah menyebabkan masalah yang bisa dilihat siapa pun. Di suatu tempat dalam stack-mu sekarang, ada asterisk yang diam-diam berarti dua hal sekaligus. Cari sebelum seseorang dengan password yang sangat panjang menemukannya lebih dulu.