はじめに
2026年10月、企業からの個人情報漏えいの公表が続いています。マクニカ セキュリティ研究センターの集計では、国内法人が運営するWebシステム経由の漏えい公表は 2024年62件、2025年84件、2026年は10月6日時点で119件。今年の119件のうち約68%(81件)は7月以降に公表されたものです1。
ニュースを見ると「大変だな」で終わりがちですが、同じ集計にはもうひとつ気になる数字があります。7月以降の81件のうち 約8割は、公表された内容だけでは原因を分類できない1。つまり「どこから漏れたか」は、外からはほとんど分かりません。
だからこそ、自分が作っている・運用しているWebアプリは自分で確かめるしかない。この記事では、漏えいにつながりやすい5つの入口を、実際に動かしたコード付きで確認していきます。
この記事で分かること
- 漏えい事例で実際に起きた「構造」と、自分のコードで同じ構造を探す方法
- Python(FastAPI などを想定)と Java(Spring Boot を想定)での安全な書き方
- 今日から回せる確認コマンド
対象読者
- Webアプリを作っている、または運用している開発者
- 「セキュリティは大事」と分かっているが、何から見ればいいか迷っている人
全体図:漏れる場所は5か所に絞れる
| # | 入口 | 確認すること | 事例との対応 |
|---|---|---|---|
| 1 | 依存ライブラリ | 既知の脆弱性を放置していないか | LiteLLM のSQLインジェクション(CVE-2026-42208) |
| 2 | 認証 | パスワードをハッシュ化して保存しているか | コミックシーモア(ハッシュ化パスワードが流出) |
| 3 | データベース | SQLに値を文字列連結していないか | LiteLLM と同じ構造 |
| 4 | アップロードファイル | URLを知るだけで開けないか | 推測しやすいURLで配信される本人確認書類など(よくある事故パターン) |
| 5 | ログ | トークンや個人情報を書いていないか | コミックシーモア(システムログの一部も取得) |
順番に見ていきます。
1. 依存ライブラリ:既知の脆弱性を放置していないか
事例
LLMのプロキシとして広く使われている LiteLLM で、2026年4月に SQLインジェクションの脆弱性(CVE-2026-42208、CVSS 9.3)が公開されました。原因は、APIキーを確認するDBクエリで、呼び出し元が送ってきたキーの値をクエリ文字列にそのまま混ぜていたこと。認証なしで届く経路があり、修正版は 1.83.7 です2。
公開から36時間以内に悪用が観測されたと報じられ3、CISA の「悪用が確認された脆弱性カタログ(KEV)」にも5月8日に追加されています4。
ここで大事なのは「LiteLLM が悪い」ではなく、自分が使っているパッケージにも同じことが起きるという点です。問題は、起きたときに気づけるかどうか。
確認:pip-audit を回す
Python なら pip-audit が、インストール済みパッケージや requirements を脆弱性DBと突き合わせてくれます。
pip install pip-audit
pip-audit # 今の環境を監査
pip-audit -r requirements.txt # requirements を監査
試しに、修正前のバージョンを固定した requirements を監査してみます。
litellm==1.83.0
$ pip-audit -r requirements-sample.txt --no-deps
Found 24 known vulnerabilities in 1 package
Name Version ID Fix Versions
------- ------- -------------- ------------
litellm 1.83.0 PYSEC-2026-391 1.83.7
...(省略)
1つのバージョンを固定したままにしていただけで、24件の既知脆弱性が出てきました。PYSEC-2026-391 が上で触れたSQLインジェクションです。
今日やること
-
pip-audit(Python)やnpm audit(Node.js)をCIに入れる - Java / Spring Boot なら OWASP Dependency-Check か、GitHub の Dependabot アラートを有効にする
- 「動いているから触らない」でバージョンを固定し続けない
2. 認証:パスワードをハッシュ化して保存しているか
事例
電子書籍サービス「コミックシーモア」を運営するNTTソルマーレは、2026年10月18日の第3報で、流出が確認された会員情報として メールアドレス、ハッシュ化されたパスワード、生年月日、性別、ニックネーム を挙げています5。
注目したいのは「ハッシュ化された」の部分です。同社は「元の文字列を復元できない形に変換されたもの」と説明しています5。平文で保存していたら、お詫び文の内容はもっと深刻になっていたはずです。
ただし、ハッシュ化されていても安全とは限りません。使い回しのパスワードは、流出したメールアドレスと組み合わせて他サービスへのログインに使われます。同社も、同じ組み合わせを他サービスで使っている場合の変更を呼びかけています5。
確認:bcrypt / Argon2 で保存しているか
自分のアプリで users.password カラムの中身を1件見てください。$2b$ や $argon2id$ で始まっていなければ、見直し対象です。
import bcrypt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
password = "correct horse battery staple"
# bcrypt
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
print("bcrypt:", hashed.decode())
print("bcrypt verify (正):", bcrypt.checkpw(password.encode(), hashed))
print("bcrypt verify (誤):", bcrypt.checkpw(b"wrong password", hashed))
# 同じパスワードでもソルトが違うのでハッシュは毎回変わる
print("same input, different hash:", bcrypt.hashpw(password.encode(), bcrypt.gensalt()) != hashed)
# Argon2id(現在の推奨)
ph = PasswordHasher() # 既定で argon2id
h = ph.hash(password)
print("argon2id:", h)
print("argon2 verify (正):", ph.verify(h, password))
try:
ph.verify(h, "wrong password")
except VerifyMismatchError:
print("argon2 verify (誤): VerifyMismatchError")
実行結果:
bcrypt: $2b$12$a8dOf9SYhcIAZ97breIty.yvgqDg37HDpUuMZwMLSBZsKaGP8JLlm
bcrypt verify (正): True
bcrypt verify (誤): False
same input, different hash: True
argon2id: $argon2id$v=19$m=65536,t=3,p=4$fHFixZ7WPlAVBgKn/Q9DtQ$OKIhQOX82VyhCarI/s4cy1coRHCfzEAz0vJU6OHmz0w
argon2 verify (正): True
argon2 verify (誤): VerifyMismatchError
ポイントは3つです。
- ソルト付きなので、同じパスワードでもハッシュは毎回違う(レインボーテーブルが効かない)
- 意図的に遅いアルゴリズムなので、総当たりのコストが上がる
- SHA-256 などの高速ハッシュを「ハッシュ化」と呼んでいるコードは、ここが抜けている
Spring Boot なら Spring Security の PasswordEncoder を使います。PasswordEncoderFactories.createDelegatingPasswordEncoder() を使うと、ハッシュ値の先頭に {bcrypt} のような識別子が付き、将来アルゴリズムを変えても既存ユーザーがログインできます。
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
今日やること
- DBのパスワード列を1件見て、
$2b$/$argon2id$/{bcrypt}で始まっているか確認する - 自前の MD5 / SHA-256 を見つけたら、次回ログイン時に再ハッシュする移行処理を入れる
3. データベース:SQLに値を文字列連結していないか
事例
1で触れた LiteLLM の脆弱性は、「値をクエリ文字列に混ぜていた」という、SQLインジェクションの中でも最も基本的な構造でした2。2026年になっても、実績あるOSSで同じことが起きます。
確認:プレースホルダで渡しているか
危ない書き方と安全な書き方を並べます。攻撃文字列は使わず、'(引用符)を含む正当な値を渡すだけで違いが分かります。
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE api_keys (key TEXT PRIMARY KEY, owner TEXT)")
conn.execute("INSERT INTO api_keys VALUES ('sk-abc123', 'alice')")
conn.execute("INSERT INTO api_keys VALUES ('sk-o''brien', 'brian')") # 引用符を含むキー
def find_owner_unsafe(key: str):
# NG: 値を文字列として連結している(LiteLLMの脆弱性と同じ構造)
sql = f"SELECT owner FROM api_keys WHERE key = '{key}'"
return conn.execute(sql).fetchone()
def find_owner_safe(key: str):
# OK: 値はプレースホルダで渡す。SQLの構文とデータが分離される
return conn.execute("SELECT owner FROM api_keys WHERE key = ?", (key,)).fetchone()
print("safe :", find_owner_safe("sk-abc123"))
print("safe :", find_owner_safe("sk-o'brien")) # 引用符入りでもそのまま値として扱われる
try:
print("unsafe :", find_owner_unsafe("sk-o'brien"))
except sqlite3.Error as e:
print("unsafe : SQLエラー ->", e) # 引用符で構文が壊れる = 攻撃者が構文を操作できる入口
実行結果:
safe : ('alice',)
safe : ('brian',)
unsafe : SQLエラー -> near "brien": syntax error
unsafe 側は、ただの引用符でSQLの構文が壊れています。入力でSQLの構文を変えられるということは、攻撃者が構文を好きに組み替えられるということです。safe 側は、引用符を含んでいても「そういう値」として扱われます。
Java(JDBC)なら PreparedStatement で同じことができます。
public Optional<String> findOwner(String apiKey) throws SQLException {
String sql = "SELECT owner FROM api_keys WHERE key = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, apiKey);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(rs.getString("owner")) : Optional.empty();
}
}
}
今日やること
- コードベースを
f"SELECT"SELECT ... " +String.format("SELECTで検索する - ORM(SQLAlchemy、JPA)を使っていても、
text()や@Query(nativeQuery = true)の中で連結していないか見る
4. アップロードファイル:URLを知るだけで開けないか
事例
本人確認書類や明細書のような画像を、/uploads/12345.jpg のような推測しやすいURLで、ログインなしで返しているケースがあります。「URLは誰にも教えていないから大丈夫」は、設計としては成り立ちません。
確認:認可チェックか、期限付きの署名付きURLか
正攻法は「ファイルを返す前に、ログイン中のユーザーがそのファイルの持ち主か確認する」です。CDNやストレージから直接配信したい場合は、期限付きの署名付きURLを使います。仕組みが分かるように、最小構成を書きます。
import hmac, hashlib, time, secrets
SECRET = secrets.token_bytes(32) # 実際は環境変数などから読み込む
def make_token(user_id: str, file_id: str, ttl_sec: int = 300) -> str:
exp = int(time.time()) + ttl_sec
msg = f"{user_id}:{file_id}:{exp}".encode()
sig = hmac.new(SECRET, msg, hashlib.sha256).hexdigest()
return f"{exp}.{sig}"
def verify_token(user_id: str, file_id: str, token: str) -> bool:
try:
exp_str, sig = token.split(".")
exp = int(exp_str)
except ValueError:
return False
if time.time() > exp:
return False # 期限切れ
msg = f"{user_id}:{file_id}:{exp}".encode()
expected = hmac.new(SECRET, msg, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig) # タイミング攻撃を避ける比較
# ログイン中のユーザー alice が、自分のファイル doc-001 のURLを発行
t = make_token("alice", "doc-001")
print("alice -> doc-001 :", verify_token("alice", "doc-001", t))
print("alice -> doc-002 :", verify_token("alice", "doc-002", t)) # 別ファイルには使えない
print("bob -> doc-001 :", verify_token("bob", "doc-001", t)) # 他人は使えない
print("改ざん :", verify_token("alice", "doc-001", t[:-1] + "0"))
expired = make_token("alice", "doc-001", ttl_sec=-1)
print("期限切れ :", verify_token("alice", "doc-001", expired))
実行結果:
alice -> doc-001 : True
alice -> doc-002 : False
bob -> doc-001 : False
改ざん : False
期限切れ : False
本番では、Azure Blob Storage の SAS や AWS S3 の署名付きURLなど、クラウド側の仕組みを使うのが楽です。やっていることは上と同じで、誰が・どのファイルを・いつまでを署名で縛っています。
今日やること
- ブラウザのシークレットウィンドウで、自分のアプリのファイルURLを直接開いてみる
- 開けたら、認可チェックか署名付きURLに切り替える
5. ログ:トークンや個人情報を書いていないか
事例
コミックシーモアの第3報では、会員情報に加えて 「一部のシステムログファイルも閲覧・取得された」 と説明されています(会員情報が含まれるかは精査中)5。
ログは「漏れたときに何が入っているか」を事前に決められる場所です。認証ヘッダやAPIキーをそのままログに書いていると、ログの流出がそのまま認証情報の流出になります。
確認:ログにマスク用のフィルタを入れる
Python の logging なら、Filter を1つ足すだけで出力前にマスクできます。
import logging, re
class MaskFilter(logging.Filter):
PATTERNS = [
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"), # Authorizationヘッダ
(re.compile(r"\bsk-[A-Za-z0-9]+"), "sk-***"), # APIキー
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), "***@***"), # メールアドレス
]
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
for pat, rep in self.PATTERNS:
msg = pat.sub(rep, msg)
record.msg, record.args = msg, ()
return True
logger = logging.getLogger("app")
handler = logging.StreamHandler()
handler.addFilter(MaskFilter())
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.info("auth failed: header=Bearer eyJhbGciOi.abc.def user=alice@example.com key=sk-abc123")
実行結果:
auth failed: header=Bearer *** user=***@*** key=sk-***
Spring Boot(Logback)なら、PatternLayout の %replace() や、マスク用の MessageConverter を定義して同じことができます。
今日やること
- ログを
grep -E "Bearer|sk-|@"で検索して、生の値が出ていないか見る - リクエスト全体をダンプするデバッグログが本番で残っていないか確認する
まとめ:5分でできる確認リスト
-
pip-audit/npm audit/ Dependabot が回っている -
パスワード列が
$2b$/$argon2id$/{bcrypt}で始まっている -
f"SELECTや"SELECT ... " +で検索してヒットしない - シークレットウィンドウでファイルURLを直接開いても見えない
-
ログを検索しても
Bearerやメールアドレスの生の値が出てこない
5つとも、新しい技術は何も要りません。公表された漏えいの約8割は原因が外から分からない1。だからこそ、自分のアプリは自分で確かめておく価値があります。
所感
事例を追っていて感じたのは、起きていることの多くが「新しい攻撃」ではなく「基本の抜け」だということです。SQLの文字列連結は、研修のコードレビューでも今なお見かけます。だからこそ、派手な対策より先に、この5か所を定期的に見直す習慣のほうが効きます。
検証環境:Python 3.13 / bcrypt 5.0.0 / argon2-cffi / pip-audit。Java の PreparedStatement のコードは JDK でコンパイル確認しています。
-
マクニカ セキュリティ研究センター「相次ぐWEBシステムからの情報漏洩事案について」(2026年10月7日) https://security.macnica.co.jp/blog/2026/10/web-incidents2026.html ↩ ↩2 ↩3
-
GitHub Security Advisory GHSA-r75f-5x8p-qvmc(CVE-2026-42208) https://github.com/BerriAI/litellm/security/advisories/GHSA-r75f-5x8p-qvmc ↩ ↩2
-
Security Affairs「CVE-2026-42208: LiteLLM bug exploited 36 hours after its disclosure」(2026年4月29日) https://securityaffairs.com/?p=191483 ↩
-
The Hacker News「LiteLLM CVE-2026-42208 SQL Injection Exploited within 36 Hours of Disclosure」(2026年4月29日) https://thehackernews.com/2026/04/litellm-cve-2026-42208-sql-injection.html ↩
-
NTTソルマーレ プレスリリース(コミックシーモアへの不正アクセスに関する第3報、2026年10月18日) https://www.nttsolmare.com/press/2026/1016-1.html ↩ ↩2 ↩3 ↩4


