たれながし.info

とあるITエンジニアの備忘録

FortiGateの「Web管理画面」と「SSL-VPNポータル」のトップページに違いはあるか?



FortiGateの「Web管理画面」と「SSL-VPNポータル」はいずれもHTTPSでサービスが提供されています。

異なるTCPポートでサービスを提供する必要がありますが、以下のような共通点もあるので、それぞのトップページに違いがあるか気になって調べてみた。

  • HTTPSでサービスが提供される
  • トップページにID/パスワードを入力するフォームが存在する

検証環境の構築

検証環境は、Azure VMでFortigateの仮想マシンを作成して利用しました。

  • 環境:FortiGate-VM 7.4.1

FortiGate-VMの構築

AzureのMarket Placeにある「Fortinet FortiGate Next-Generation Firewall」を利用しました。

AzureでのFortiGate-VMの作成方法は以下記事を参照ください。
ライセンスは「Pay as You Go」を選択しています。

tarenagashi.hatenablog.jp

SSL-VPNの設定

FortiGateでSSL-VPNポータルを表示するには、SSL-VPN関連の設定を行う必要があります。
SSL-VPN Settings」と「Firewall PolicyでSSL-VPNユーザを許可するポリシー」の2つをとりあえず設定すれば、SSL-VPNポータルを表示することができます。

設定手順はメーカーサイトを参照ください。
docs.fortinet.com

比較の実施

トップページの見た目の比較

「Web管理画面」と「SSL-VPNポータル」のトップページの見た目を比較しました。
明らかな違いがあります。「SSL-VPNポータル」の方は、「SSL-VPN Portal」と書いてあります。

今回はファームウェア 「Ver 7.4.1」を使用しましたが、「Ver 6.X」だと「SSL-VPNポータル」も緑色が使用されたシンプルな画面なので、「Web管理画面」との違いが分かり難いです。
しかし、「Ver 7.4.1」「Ver 6.X」のいずれの「SSL-VPNポータル」にも、「FortiClientを起動」というボタンが存在するので、そのボタンの有無により容易に「Web管理画面」と「SSV-VPNポータル」の違いを判断することができます。

Web管理画面


SSV-VPNポータル


証明書情報の比較

「Web管理画面」と「SSL-VPNポータル」の証明書情報を比較しました。
証明書のサブジェクトに違いがあります。特にコモンネームが分かり安いです。

「Web管理多面」のデフォルトの証明書のコモンネームは「CN = FortiGate」となっています。
「SSV-VPNポータル」のデフォルトの証明書のコモンネームは「CN = FGTAZRHTXXLQFNE0」というようにシリアル番号になっています。

Web管理画面

Defaultの証明書の識別名は「Fortinet_GUI_Server」、サブジェクトは以下です。

CN = FortiGate
O = Fortinet Ltd.

 

SSV-VPNポータル

Defaultの証明書の識別名は「Fortinet_Factory」、サブジェクトは以下です。

E = support@fortinet.com
CN = FGTAZRHTXXLQFNE0
OU = FortiGate
O = Fortinet
L = Sunnyvale
S = California
C = US

 

結論

FortiGateの「Web管理画面」と「SSL-VPNポータル」のトップページの違いは、以下2点で判別することができます。

  1. 「FortiClientを起動」ボタンの有無
  2. Default証明書のサブジェクト、特にコモンネーム


項目 Web管理画面 SSL-VPNポータル
「FortiClientを起動」ボタンの有無 無し 有り
Default証明書のサブジェクトのコモンネーム CN = FortiGate CN = FGTAZRHTXXLQFNE0
※個体により異なるシリアル番号

ANA国内線の機内Wi-Fiに接続してみた

はじめに

先日、羽田発→函館行きのANAの国内線に乗りました。機内Wi-Fiが無料だったので接続してみました。
その時のSSIDIPアドレスなど、個人的なメモです。

乗った機体は「ボーイング787-8(78P)」でした。


ANA国内線の機内Wi-Fiについて

今回機内Wi-Fi使った感想として、遅さは感じませんでした(使ってた人が少ないだけかもしれません)

接続結果のメモ

Wi-Fi接続

SSIDは「ANA-WiFi-Service」でした。
Wi-Fi接続にPW入力は無かったが、ブラウザでの認証が必要でした。

  

グローバルIPアドレス

グローバルIPは「205[.]220[.]148[.]142」でした。

Panasonic Avionics Corporation」は、205.220.128[.]0~205.220.159[.]255の範囲 = 8192個のグローバルIPを所持してるみたいです。

プライベートIPアドレス

端末に割り当てられたプライベートIPアドレスは以下のとおりでした。

IPアドレス 172.19.248.97/23
デフォゲ兼DNS 172.19.248.1

/23のレンジなので、飛行機1台辺り最大509台のクライアント端末からの接続を想定しているようです。
(2^9=512から、NWアドレス、BroadCastアドレス、デフォゲ兼DNSの3つを除く)

私の端末に割り当てられたプライベートIPの第4オクテットは「.97」となってましたが、96台の端末が接続されている(最大96人が利用している)わけではなく、過去フライト時のDHCPのキャッシュが残っている場合「.2」から順に割り当てられているわけではないかもしれません。

おわりに

下記も調べてみたいなと後から思いました。

  • デフォゲのMACアドレス取得(からの親機製造メーカーの推定)
  • インターネット宛のtraceroute
  • プライベートIP(/23)に対するPing疎通確認やARPの送信
  • 認証画面のホスト「inflight.pacwisp[.]net」のIPアドレスやHTTPヘッダ

PythonでTor経由のスクレイピング

はじめに

.onionドメインのWebサイトをスクレイピングしたいと思い調べたところ、ブラウザなどのWebクライアントからの通信がTorのSOCKSプロキシを経由するように構成すればスクレイピングできるとのことで、いくつかのWebクライアントを利用して実施してみます。

.onionドメインでない通常のWebサイト(*.comとか、*.jpなど)について、送信元IPアドレスを隠蔽してスクレイピングしたい場合もこの方法で可能です。

※ちなみに、WebサイトによってはTor経由のアクセスを禁止してたり、Tor経由だとreCAPTCHAが動作するサイトがあるので、そういう場合は適宜対応や諦める必要があります。

実施環境について

  • OS:Windows11 Enterprise 22H2 64bit
  • Python:3.10.0
  • Torブラウザ:バージョン不明 ※2023/10/04時点の最新
  • Chrome:バージョン117

Torブラウザ付属のTorについて

Torブラウザを利用してTorへの通信を確立すると、Windows版の場合は「tor.exe」というプログラムがTCPの9150と9151で待ち受けて起動します。

9150がTorネットワークに接続するためのSOCKSプロキシ、9151が制御ポートです。
(制御ポートを使うと、Torの設定や状態確認ができるらしい)

> $tor_process = (Get-Process | where ProcessName -eq "tor").id
> Get-NetTCPConnection | where State -eq "Listen" | where OwningProcess -eq $tor_process | Select-Object LocalAddress,LocalPort,State

LocalAddress LocalPort  State
------------ ---------  -----
127.0.0.1         9151 Listen
127.0.0.1         9150 Listen

スクレイピングの実施

3種類のWebクライアントで、.onionドメインのWebサイトをスクレイピングする例です。
htmlの取得までを実施します(htmlを取得できれば後は解析するだけなので)。

TorのSOCKSプロキシを利用するので、事前にTorブラウザを起動し、Torネットワークへの接続を確立しておく必要があります。


.onionドメインのサイトは「Facebook」と「DuckDuckGo」の.onionドメインVerを利用しました。

Webクライアントが「Chrome」の場合

Webクライアントが「Chrome」の場合です。
Selenium+ChromeDriver経由でChromeを操作してスクレイピングを実施します。

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

options = Options()
options.add_argument("--proxy-server=socks5://127.0.0.1:9150")

driver = webdriver.Chrome(options=options)
driver.get("https://facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd.onion")
print(driver.page_source)

Webクライアントが「requests」の場合

Webクライアントが「requests」の場合です。
https://requests.readthedocs.io/en/latest/

import requests

proxies = {
    'http' : "socks5h://localhost:9150",
    'https' : "socks5h://localhost:9150"
}

url = "https://facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd.onion"
res = requests.get(url, proxies=proxies)
print(res.text)

Webクライアントが「requests_tor」の場合

Webクライアントが「requests_tor」の場合です。
https://github.com/deedy5/requests_tor

「requests_tor」は、Tor接続に特化したPythonのWebクライアントです。
TorブラウザのHTTPヘッダを模倣したり(User-Agentなど)、複数サイトへの同時接続などができます。

from requests_tor import RequestsTor

rt = RequestsTor(tor_ports=(9150,), tor_cport=9151)

# 単一サイトへの接続
url = 'https://facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd.onion'
r = rt.get(url)
print(r.text)

# 複数サイトへの接続
urls = ['https://facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd.onion', 
        'http://duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion']

for r in rt.get_urls(urls):
    print(r.url)
    print(r.text)

ログイン画面が改ざんされたIoTルーターをCenSysで検索してみた


はじめに

福島第一原発の処理水放出に反対するハッカーグループが、セイコーソリューションズが発売する「SkyBridge」と「SkySpider」というIoTルーターのログイン画面を書き換えるという事件が起こっています。

「CenSys」というサービスを使って、ログイン画面が改ざんされたIoTルーターを検索してみます。

CenSysについて

CenSysは、インターネットに接続された機器を検索できるサーチエンジンです。
CenSysは全機能を無料利用できます。アカウント未登録時は利用制限がありますが、解除するにはアカウント登録をすればよく、アカウント登録も無料です。

search.censys.io

同様のサービスにはSHODANがあります。
SHODANは一部機能は無料ですが、制限以上のことをするにはアカウント登録と有料のメンバーシップ登録が必要です。

www.shodan.io

IoTルーターのログイン画面書き換え事件について

今回の福島第一原発の処理水放出に反対するハッカーグループが、セイコーソリューションズが発売する「SkyBridge」と「SkySpider」というIoTルーターのログイン画面を書き換えるという事件についてはすでにネットニュースなどで報道されています。

news.yahoo.co.jp

書き換えられたログイン画面

書き換えられたログイン画面の画像です。※絵文字が文字化けしてます。

書き換えられた技術的な原因

IoTルーターファームウェア脆弱性が悪用されたようです。この脆弱性については、修正されたファームウェアが既に出ているようです。
また、必要がなければログイン画面はインターネットに公開しない方が良いと思います。

www.ipa.go.jp

CenSysでログイン画面が改ざんされたIoTルーターを検索する

まず、どうにかしてログイン画面が改ざんされたIoTルーターの特徴を見つけます。
今回は、改ざんされるとログイン画面のタイトルが特定の文字列となることがわかりました。

HTMLのページタイトルに特定の文字列が含まれるサイトは「services.http.response.html_title: "検索文字列"」で検索可能です。
検索オプションは、「Search 2.0 Example Host Queries」を参照しました。

09/04(月)午前の時点で「1217台」の端末がみつかりました。
2~3日前は「1450台程度」がヒットしていたため、改ざんされたIoTルーターは少しずつ対処されているようです。

PythonでGmailからメール送信(SMTP使用)

はじめに

PythonSMTPを使用してGmailからメール送信する方法になります。

GmailSMTPサーバについて

サーバとポート番号

GmailSMTPサーバは「smtp.gmail.com」です。

Gmailがサポートするポート番号はTCP465(TLS)、TCP587(STARTTLS)の2つです。
Pythonの標準SMTPクライアント「smtplib」はTLSをサポートするので、ポート番号「TCP465(TLS)」を使用します。

アプリパスワードについて

Gmailを使用するGoogleアカウントで「2段階認証プロセス」が有効になっている場合、Pythonプログラムからはアプリパスワードを使用してGmailに認証する必要があります。
アプリパスワードはGoogleアカウントの設定画面から取得可能です。

support.google.com

PythonGmail送信の実施

アプリパスワードの取得

私のGoogleアカウントは「2段階認証プロセス」が有効なので、「アプリパスワード」を取得します。
[Googleアカウント]の設定画面(https://myaccount.google.com)から [セキュリティ] > [2段階認証プロセス] > [アプリパスワード]に移動します。

アプリは「メール」、デバイスは「その他(名前を入力)」>「Gmail Python」としました。
※適当に設定しても問題ないと思われる


Pythonプログラム

SMTPを操作するため、Pythonの標準モジュール「smtplib」を使います。
件名や本文をMIMEテキストにエンコードするため、Pythonの標準クラス「email.mime.text.MIMEText」を使います。

import smtplib
from email.mime.text import MIMEText

# パスワード
password = "xxxxxxxxxxxx"

# 送信アカウント、宛先
sender = "test@gmail.com"
recipients = ["test@test.local", "test2@test2.local"]

# 件名、本文
subject = "テストメール"
body = """このメールはテストメールです。
以上、よろしくお願いいたします。"""

def send_email(subject, body, sender, recipients, password):
    msg = MIMEText(body)
    msg['Subject'] = subject
    msg['From'] = sender
    msg['To'] = ', '.join(recipients)
    with smtplib.SMTP_SSL('smtp.gmail.com', 465) as smtp_server:
       smtp_server.login(sender, password)
       smtp_server.sendmail(sender, recipients, msg.as_string())
    print("Message sent!")

send_email(subject, body, sender, recipients, password)

動作確認

無事メールの送受信を確認しました。

Windowsでシステムロケールを確認する方法


はじめに

OSの言語環境によって動作が変わるアプリケーションが存在します。
例えば、ロシア製のマルウェアは、ロシア語環境のOSでは動作しないといったことがあるようです。

Windowsでは、アプリがOSの言語環境を判別する方法として「システムロケールを確認する」という方法があるようです(これ以外の方法もあると思います)。
ということで、「Windowsでシステムロケールを確認する方法」を調べてみました。

MSの公式文書でのシステムロケールの説明は以下にあります。
learn.microsoft.com

※ちなみに
Windowsの言語設定には「ユーザーロケール」「システムロケール」「入力ロケール」「キーボードロケール」etcと色々あるみたいですが、「システムロケール」がそのOSが利用されている言語環境を意味していると思って概ね問題ないのかな思います(特殊な利用環境でしか、それぞれを異なる設定にはしないと思われる)。

システムロケールの表記について

システムロケールは「[言語コード]-[国/地域コード]」という形で表現され、それぞれにLCID(Windows Language Code Identifier)という識別IDが付いているみたいです。

昨今ニュースでよく見聞きする国/地域のロケールは以下の通りです。

[言語コード]-[国/地域コード] [言語コード] [国/地域コード] [LCID(10進数)] [LCID(16進数)]
ja-JP 日本語 日本 1041 0x0411
en-US 英語 アメリ 1039 0x0409
ru-RU ロシア語 ロシア 1049 0x0419
uk-UA ウクライナ ウクライナ 1058 0x0422
be-BY ベラルーシ ベラルーシ 1059 0x0423
zh-CN 中国語(繁体字 中国 2052 0x0804
zh-TW 中国語(簡体字 台湾 1028 0x0404
ko-KR ハングル 韓国 1042 0x0412
ko-KP ハングル 北朝鮮 未定義 未定義

MSが公開しているLCIDのリファレンスです。
learn.microsoft.com

システムロケールの確認方法

幾つかの方法で、Windowsのシステムロケールを確認してみます。

GUIで確認

コントロールパネルから確認可能です。
※時計と地域 > 地域 > Unicode対応でないプログラムの言語 > システムロケールの変更


PowerShellで確認

Get-WinSystemLocale」で確認可能です。

> Get-WinSystemLocale

LCID             Name             DisplayName
----             ----             -----------
1041             ja-JP            日本語 (日本)

WinAPIで確認

WinAPIだと「GetSystemDefaultLocaleName」と「GetSystemDefaultLCID」で確認可能です。
Vista以降だと、「GetSystemDefaultLocaleName」の使用が推奨されています。

Pythonのctypesで該当APIを呼び出す例です。

from ctypes import *
info = create_string_buffer(100)
WinDLL('kernel32').GetSystemDefaultLocaleName(byref(info), len(info))
print(info.raw.decode("s-jis"))

> ja-JP
from ctypes import *
print(WinDLL('kernel32').GetSystemDefaultLCID())

> 1041

PythonのctypesでRemote DLL Injectionしてみる


はじめに

「DLL Injection」にはいくつかの方法があって、Windows APIの「CreateRemoteThread」を使う方法は結構メジャーらしい。この方法を「Remote DLL Injection」とか「CreateRemoteThreadを使ったDLL Injection」と呼ぶみたいです。

「Remote DLL Injection」を実施するには、「CreateRemoteThread」を含むいくつかのWindows APIの実行が必要です。Windows APIを実行するには、C言語C++を使うことが一般的ですが(私がそれらの言語に慣れておらず)面倒なので、Pythonのctypesを使って実施してみます。

ctypesについて

ctypesは、C言語 と互換性のあるデータ型の提供と、動的リンク/共有ライブラリ内の関数呼び出しを可能とするPythonのライブラリになります。
docs.python.org

嵌ったポイント

ctypesを使うにあたり、変数の型の扱いで嵌りました。

ctypesで外部関数を実行する場合、argtypesで引数の型、restypeで戻り値の型を指定できます。
これを適切に行わないと外部関数が正しく動かない場合がありました。

以下は、型の指定を行わないで、Windows API「VirtualAllocEx/WriteProcessMemory」を実行した例です。
VirtualAllocExで割り当てたメモリ領域に、WriteProcessMemoryで書きこむ動作になります。
Pythonのエラーは発生しませんでしたが、WriteProcessMemoryの実行が998(ERROR_NOACCESS)で失敗しました。

<プログラム>
mem_address = kernel32.VirtualAllocEx(handle, 0, size, MEM_COMMIT, PAGE_READWRITE)

ret = kernel32.WriteProcessMemory(handle, mem_address, dll_pass.encode(), c_size_t(len(dll_pass)), byref(c_size_t(0)))
print("WriteProcessMemory(0以外なら成功): {}".format(ret))
print("GetLastError:{}\n".format(kernel32.GetLastError()))

<結果>
WriteProcessMemory(0以外なら成功): 0
GetLastError:998


ctypesで外部関数を実行した場合、デフォルトの動作では、戻り値はC言語のint型(32bit)として扱うそうです。
VirtualAllocExの戻り値は割り当てられたメモリのアドレスとなり、64bit環境だと64bitになるので、C言語のint型(32bit)に収まらず、引数として渡されたWriteProcessMemoryの実行がエラーになったと思われます。

次に、VirtualAllocExの戻り値をLPVOID型(任意の型へのポインタ)に指定してWriteProcessMemoryを実行すると、引数に関するPythonのエラーが発生します。
メモリアドレスを示す第2引数の型の不一致により、OverflowErrorが発生したという内容と思われます。

<プログラム>
kernel32.VirtualAllocEx.restype = LPVOID #追加
mem_address = kernel32.VirtualAllocEx(handle, 0, size, MEM_COMMIT, PAGE_READWRITE)

ret = kernel32.WriteProcessMemory(handle, mem_address, dll_pass.encode(), c_size_t(len(dll_pass)), byref(c_size_t(0)))
print("WriteProcessMemory(0以外なら成功): {}".format(ret))
print("GetLastError:{}\n".format(kernel32.GetLastError()))

<結果>
ctypes.ArgumentError: argument 2: <class 'OverflowError'>: int too long to convert


最後に、WriteProcessMemoryの引数の型も指定すると、WriteProcessMemoryの実行が無事成功しました。

<プログラム>
kernel32.VirtualAllocEx.restype = LPVOID
mem_address = kernel32.VirtualAllocEx(handle, 0, size, MEM_COMMIT, PAGE_READWRITE)

kernel32.WriteProcessMemory.argtypes = [HANDLE, LPVOID, LPCVOID, c_size_t, POINTER(c_size_t)] #追加
ret = kernel32.WriteProcessMemory(handle, mem_address, dll_pass.encode(), c_size_t(len(dll_pass)), byref(c_size_t(0)))
print("WriteProcessMemory(0以外なら成功): {}".format(ret))
print("GetLastError:{}\n".format(kernel32.GetLastError()))

<結果>
WriteProcessMemory(0以外なら成功): 1
GetLastError:0


ということで、ctypesで外部関数を実行する場合、argtypesで引数の型、restypeで戻り値の型を指定した方が良いようです。
厳密にどんな時に指定が必要かはよく分かっていないのですが、下記場合は最低でも型指定した方が良いと認識しました(全ての外部関数で指定した方が無難なのかも)。

  • restypeの指定:外部関数の戻り値がC言語のint型として扱われると困る場合
  • argtypesの指定:型指定で受け取った他外部関数の戻り値を引数に使用して外部関数を呼び出す場合

事前準備

環境

Windows 11 Enterprise 22H2(64bit)
Python 3.10.0

DLLの作成

リモートプロセスに読み込ませるDLLを準備します。以前作成したものを使用しました。
tarenagashi.hatenablog.jp

Remote DLL Injectionの実施

コードの作成

notepad.exeを起動して、自作のDLL(c:\testdll.dll)を注入するコードになります。
一部の外部関数でしか引数と戻り値の型を指定していませんが、動作したので今回はこれで良いことにします。

from ctypes import * 
from ctypes.wintypes import *
import subprocess

# リモートプロセスの起動
proc = subprocess.Popen("notepad.exe")
pid = proc.pid

# プロセスハンドルの取得
VM_READ, VM_WRITE, VM_OPERATION = 0x0010, 0x0020, 0x0008 
DesiredAccess = VM_READ|VM_WRITE|VM_OPERATION
kernel32 = WinDLL("kernel32")

handle = kernel32.OpenProcess(DesiredAccess, False, pid)

print("プロセスハンドル: {}".format(hex(handle)))
print("GetLastError:{}\n".format(kernel32.GetLastError()))

# プロセスへのメモリ割り当て(VirutualAllocEx)
size = 0x27
MEM_COMMIT = 0x1000|0x2000
PAGE_READWRITE = 0x04

kernel32.VirtualAllocEx.restype = LPVOID
mem_address = kernel32.VirtualAllocEx(handle, 0, size, MEM_COMMIT, PAGE_READWRITE)

print("VirtualAllocExの戻り値(確保したページ領域のベースアドレス): {}".format(hex(mem_address)))
print("GetLastError:{}\n".format(kernel32.GetLastError()))

# 割り当てたメモリにDLLのパスをコピー(WriteProcessMemory)
dll_pass = "c:\\testdll.dll"

kernel32.WriteProcessMemory.argtypes = [HANDLE, LPVOID, LPCVOID, c_size_t, POINTER(c_size_t)]
kernel32.WriteProcessMemory.restypes = BOOL
ret = kernel32.WriteProcessMemory(handle, mem_address, dll_pass.encode(), c_size_t(len(dll_pass)), byref(c_size_t(0)))

print("WriteProcessMemory(0以外なら成功): {}".format(ret))
print("GetLastError:{}\n".format(kernel32.GetLastError()))

# LoadLibrary関数のアドレスの取得(GetModuleHandleA)
kernel32.GetModuleHandleA.restype = HMODULE
h_module = kernel32.GetModuleHandleA(b"kernel32.dll")

print("kernel32.dll のモジュールハンドル: {}".format(hex(h_module)))
print("GetLastError:{}\n".format(kernel32.GetLastError()))

kernel32.GetProcAddress.argtypes = (HMODULE, LPCSTR)
kernel32.GetProcAddress.restype = LPVOID
func_addr = kernel32.GetProcAddress(h_module, b"LoadLibraryA")

print("LoadLibraryA のアドレス: {}".format(hex(func_addr)))
print("GetLastError:{}\n".format(kernel32.GetLastError()))

# 対象プロセスでリモートスレッドの実行(CreateRemoteThread)
kernel32.CreateRemoteThread.argtypes = [HANDLE, c_size_t, c_size_t, LPVOID, LPVOID, DWORD, POINTER(c_size_t)]
ret = kernel32.CreateRemoteThread(handle, c_size_t(0), c_size_t(0), func_addr, mem_address, 0, byref(c_size_t(0)))

print("CreateRemoteThreadの戻り値: {}".format(hex(ret)))
print("GetLastError:{}\n".format(kernel32.GetLastError()))

動作確認

上記スクリプトを実行すると、notepad.exeの起動後にDLLが読み込まれ、DLLMain内のMessageBoxが表示されることを確認しました。

Windows11+Python 3.10.0の環境だと特に問題ないのですが、Windows10+Python3.10.10の環境だと動作はするのですが、Windows APIの呼び出し結果が122(ERROR_INSUFFICIENT_BUFFER)となります。
以下はWindows10環境での実行時の画像です。


tasklistコマンドでも、notepad.exeに対象のDLLが読み込まれていることを確認できます。

>tasklist /m testdll.dll

イメージ名                     PID モジュール
========================= ======== ============================================
Notepad.exe                  21960 testdll.dll