Technical Article

Memperkeras Penandatangan PDF Delphi Terhadap PKCS#12 Berbahaya

Saat Anda menandatangani PDF, Anda biasanya menganggap kunci penandatanganan sebagai sesuatu yang Anda kendalikan. Kunci tersebut berada di file .pfx yang Anda buat, dilindungi oleh kata sandi yang Anda pilih. Kode yang membaca file tersebut terasa seperti pipa biasa, bukan batas keamanan. Intuisi tersebut salah saat sertifikat tersebut bukan lagi milik Anda. Alat desktop yang memungkinkan pengguna memilih .pfx apa saja, server yang menerima kredensial yang diunggah, atau penandatangan massal (batch signer) yang diberi makan sertifikat melalui jaringan, semuanya menyerahkan byte yang dipengaruhi penyerang ke parser sebelum satu byte tanda tangan dihasilkan. Pembaca PKCS#12 adalah permukaan serangan (attack surface), sama halnya seperti dekoder gambar atau pemuat font.

Artikel ini membahas dua cacat nyata yang pernah ada di pembaca tersebut, keduanya berada di jalur yang mengimpor kredensial penandatanganan. Tidak ada yang eksotis. Keduanya berasal dari penyebab utama yang sama yang menimpa hampir setiap parser biner yang ditulis dalam bahasa dengan lebar integer tetap: panjang atau jumlah dari file dipercayai satu langkah lebih jauh dari yang seharusnya. Salah satunya menyebabkan pembacaan di luar batas (out-of-bounds read), yang lain menyebabkan proses macet (hang) hingga Anda menghentikannya.

Ke mana byte berjalan

Mengimpor .pfx untuk menandatangani dokumen bukanlah satu operasi tunggal, melainkan sebuah jalur pipa pendek, dan setiap tahap mengurai sesuatu yang mungkin telah ditulis oleh penyerang. Wadahnya adalah struktur PKCS#12 seperti yang didefinisikan dalam RFC 7292, sekumpulan kantong AuthenticatedSafe yang dibungkus di sekitar pelindung terenkripsi yang menampung kunci privat. Membacanya berarti menelusuri ASN.1, menurunkan kunci dari kata sandi, mendekripsi, lalu menyerahkan kunci RSA yang dipulihkan ke kode yang membangun tanda tangan.

Di HotPDF, tahap-tahap tersebut dipetakan ke unit yang berbeda. Logika kontainer PKCS#12 berada di HPDFPFX. Setiap tag, panjang, dan nilai yang disentuhnya didekode oleh pembaca ASN.1 di HPDFASN1. Penurunan kunci dan dekripsi PBES2 berada di HPDFCrypt bersama PBKDF2HMACSHA256. Ketika kunci dipulihkan, HPDFRSA dan pembangun CMS SignedData di HPDFCMS mengubahnya menjadi tanda tangan terpisah yang disematkan dalam PDF. Titik masuk publik yang menggerakkan seluruh rantai ini adalah satu pemanggilan saja.

// Drives the full pipeline: load the placeholder PDF, parse the PFX,
// derive the key, build CMS SignedData, write the signed output.
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // signature embedded
else
  // signing did not complete
;

Setiap byte dari signer.pfx mengalir melalui HPDFASN1 and HPDFPFX sebelum kriptografi apa pun terjadi. Jika kedua unit tersebut tidak berhati-hati dengan apa yang diklaim oleh file, kriptografi di hilir tidak akan pernah memiliki kesempatan untuk berfungsi.

Cacat satu: panjang ASN.1 yang terbungkus melewati pelindung

ASN.1 dalam DER dan BER mengodekan setiap elemen sebagai tag, panjang, dan byte konten sebanyak itu. Panjang adalah kolom yang harus Anda percayai tetapi verifikasi, karena kolom tersebut memberi tahu parser seberapa jauh harus membaca, dan itu ditulis oleh siapa pun yang membuat file tersebut. X.690 §8.1.3 mendefinisikan dua pengodean. Bentuk pendek mengemas panjang 0 hingga 127 ke dalam satu byte. Bentuk panjang, digunakan untuk ukuran yang lebih besar, menghabiskan satu byte awal di mana tujuh bit rendahnya memberikan hitungan byte panjang yang mengikuti, kemudian byte big-endian sebanyak itu membawa nilai sebenarnya. Oleh karena itu, empat byte panjang dapat mendeklarasikan ukuran konten mendekati empat gigabyte.

Setelah mendekode nilai tersebut, parser harus memeriksa bahwa konten tersebut benar-benar muat di dalam buffer sebelum mempercayainya. Pemeriksaan alami adalah mengonfirmasi bahwa posisi saat ini ditambah panjang konten tidak melewati akhir data. Ditulis dengan cara biasa, dengan posisi, panjang konten, dan total semuanya ditampung dalam integer bertanda 32-bit, pelindung tersebut rusak:

// The trap: signed 32-bit arithmetic. With ContentLen near MaxInt,
// Pos + ContentLen overflows to a NEGATIVE value, so the comparison
// is false and a forged ~2 GB length sails straight through.
if Pos + ContentLen > Total then
  raise EHPDFASN1Error.Create('content overruns buffer');

Masalahnya ada pada penjumlahan, bukan perbandingan. Ketika ContentLen mendekati MaxInt (2147483647), Pos + ContentLen meluap dari rentang 32-bit bertanda dan membungkus kembali ke angka negatif. Jumlah negatif tidak pernah lebih besar dari Total, sehingga pelindung melaporkan bahwa semuanya baik-baik saja dan membiarkan parser melanjutkan dengan panjang konten sekitar dua gigabyte yang sebenarnya tidak ada di buffer. Apa yang terjadi selanjutnya adalah kerusakan: pembaca mengalokasikan buffer untuk panjang yang diklaim tersebut dan menyalin ke dalamnya, menggunakan SetLength diikuti oleh Move yang membaca dari sumber. Sumber hanya memiliki sisa beberapa ratus byte, sehingga penyalinan membaca jauh melewati akhir input, pembacaan di luar batas (out-of-bounds read) yang paling baik menyebabkan crash dan paling buruk membocorkan memori proses yang berdekatan ke dalam penguraian.

Satu-satunya pelindung yang benar adalah memperlebar penjumlahan perantara sebelum perbandingan, sehingga penjumlahan tidak dapat meluap dari tipe data tempat ia dihitung. Perbaikannya mempromosikan kedua operan menjadi Int64:

// Correct: both operands widened to Int64 before the add, so the sum
// cannot wrap. A forged 2 GB length now fails the bounds check.
if ContentLen < 0 then
  raise EHPDFASN1Error.Create('negative content length after decoding.');
if Int64(Pos) + Int64(ContentLen) > Int64(Total) then
  raise EHPDFASN1Error.Create('content overruns buffer');

Int64 menampung penjumlahan dari dua nilai 32-bit tanpa kehilangan informasi, sehingga perbandingan melihat angka yang sebenarnya dan menolak panjang yang dipalsukan. Pemeriksaan non-negatif terpisah pada ContentLen menutup kasus serupa di mana nilai terdekode menjadi negatif dengan sendirinya. Di HotPDF, pelindung ini berada di HPDFASN1ParseNode, fungsi yang menghasilkan node yang mendasari setiap helper lainnya. Karena HPDFASN1Content menentukan ukuran SetLength dan Move langsung dari panjang konten node, node yang melewati pelindung buruk akan meracuni setiap pembacaan yang diambil darinya. Memperbaiki batas pada titik dekode adalah hal yang membuat helper di atasnya aman.

Cacat dua: jumlah iterasi PBKDF2 digunakan sebagai senjata

Cacat kedua bukanlah kesalahan memori, melainkan file yang memberi tahu CPU Anda seberapa keras harus bekerja. PKCS#12 melindungi materi kuncinya dengan PBES2, skema berbasis kata sandi dari PKCS#5, yang ditentukan dalam RFC 8018. PBES2 menjalankan fungsi penurunan kunci, di sini PBKDF2 dengan HMAC-SHA-256, kemudian cipher, di sini AES-256-CBC. PBKDF2 menerima jumlah iterasi, dan hitungan tersebut adalah parameter yang dibawa dalam file. Seluruh tujuannya adalah agar lambat: lebih banyak iterasi berarti setiap tebakan kata sandi berbiaya lebih mahal, yang bagus untuk melawan penyerang offline. RFC 8018 §4.2 secara eksplisit menyatakan bahwa hitungan yang lebih besar lebih baik untuk keamanan, dan dengan sengaja tidak menetapkan batas atas.

Keterbukaan itu baik ketika Anda yang menghasilkan file tersebut. Ini menjadi senjata ketika penyerang yang melakukannya. Jumlah iterasi adalah faktor kerja yang dikendalikan penyerang, dan faktor kerja yang dikendalikan penyerang adalah denial of service kompleksitas algoritmik (algorithmic-complexity denial of service). File .pfx yang dipalsukan dapat mengodekan jumlah iterasi dalam miliaran; parser dengan patuh membacanya dan memanggil PBKDF2 untuk putaran HMAC-SHA-256 sebanyak itu, dan prosesnya menghilang ke dalam loop yang tidak akan kembali selama bermenit-menit atau berjam-jam hanya karena satu file yang disediakan. Pada server penandatanganan yang menangani satu kredensial per permintaan, satu unggahan yang dibuat secara jahat dapat menghentikan pekerja.

Hitungan tersebut membuat pembungkusan nilai menjadi lebih buruk sebelum membuat CPU berputar. Nilai iterasi berada di file sebagai INTEGER ASN.1, yang tidak memiliki lebar tetap, sementara kolom yang pada akhirnya dikonsumsi PBKDF2 adalah Integer 32-bit. Dekode INTEGER langsung ke kolom tersebut maka nilai besar akan terpotong (truncate), dan nilai yang dibuat untuk mendarat pada bit tanda akan kembali negatif atau sebagai angka kecil yang tidak terkait, sehingga ukuran pekerjaan tidak lagi seperti yang diminta oleh file. Perbaikannya membaca nilai pada lebar penuh dan membatasinya sebelum mempersempit:

// Read the iteration count as Int64 first, then clamp to a sane band
// BEFORE it is narrowed into the 32-bit Iterations field PBKDF2 uses.
LIter := HPDFASN1ToInteger(Data, Node);          // returns Int64
if (LIter < 1) or (LIter > 100000000) then
  raise EHPDFPFXError.CreateFmt(
    'PBKDF2 iteration count %d is outside the accepted range 1..100000000',
    [LIter]);
Iterations := Integer(LIter);                    // safe: already bounded

Mengapa kedua perbaikan adalah perbaikan yang sama

Kedua cacat tersebut terlihat berbeda, yang satu buffer overrun dan yang lain proses yang macet, tetapi sebenarnya itu adalah kesalahan yang sama. Dalam setiap kasus, angka dari file yang tidak tepercaya dimasukkan ke dalam tipe lebar-tetap selangkah terlalu cepat, sebelum diperiksa terhadap kenyataan. Panjang ditambahkan dalam 32 bit sebelum pengujian batas; jumlah iterasi dipersempit menjadi 32 bit sebelum pengujian batas. Keduanya tunduk pada disiplin yang sama: dekode pada lebar penuh, periksa terhadap batas nyata, dan baru setelah itu dipersempit. Int64 perantara bukanlah pilihan gaya penulisan, melainkan satu-satunya lebar di mana pelindung dapat melihat nilai yang sebenarnya ditulis oleh penyerang. Batas yang meluap bukanlah batas, dan hitungan tanpa batas atas bukanlah parameter, melainkan kendali jarak jauh atas CPU Anda sendiri.

Panduan praktis untuk alur kerja penandatanganan

Pelajaran sempitnya adalah memvalidasi input sertifikat yang tidak tepercaya seperti cara Anda memvalidasi unggahan yang tidak tepercaya. Batasi ukuran .pfx yang Anda terima, karena yang sah berukuran kilobyte, bukan megabyte. Perlakukan kegagalan penguraian sebagai input ditolak yang rutin, bukan kesalahan yang layak menampilkan pelacakan tumpukan (stack trace) ke pengguna. Jika Anda menandatangani di server, jalankan impor di mana pekerja yang macet tidak dapat merusak layanan, dan berikan batas waktu (timeout) pada operasi tersebut sehingga file yang tiba-tiba membutuhkan biaya komputasi besar dibatasi oleh waktu nyata serta batas iterasi.

Pelajaran yang lebih luas menjangkau lebih dari sekadar sertifikat. Pengerasan parser bukanlah audit satu kali pada satu unit, melainkan sifat dari setiap tempat di mana pustaka Anda membaca byte yang tidak ditulisnya sendiri. Pustaka PDF mengurai banyak hal dari sumber yang tidak tepercaya: font yang disematkan dalam dokumen, gambar dalam setengah lusin codec, filter stream, dan, pada jalur penandatanganan, sertifikat. Masing-masing dari hal tersebut adalah permukaan serangan (attack surface), dan masing-masing layak mendapatkan kecurigaan yang sama terhadap setiap panjang dan setiap hitungan. HotPDF membangun jalur impor dan penandatanganan pada unit HPDFASN1, HPDFPFX, HPDFCrypt, dan HPDFCMS yang telah diperkeras yang dijelaskan di sini, sehingga kredensial yang Anda serahkan, dari mana pun asalnya, diurai secara defensif sebelum dipercayai.

Alur kerja penandatanganan yang dilindungi oleh pemeriksaan ini dibahas secara menyeluruh dalam panduan kami tentang tanda tangan digital PAdES di Delphi, dan sikap defensif yang sama yang diterapkan pada enkripsi dokumen, termasuk jalur kunci AES-256 que shares this codebase, is described in the article on AES-256 encryption and security. All of it ships as part of the HotPDF Component for Delphi and C++Builder, alongside the loading, editing, encryption, and signing APIs covered elsewhere on this blog.