コード生成AIは安全か?LLMアプリのセキュリティ検証を実施してみた
はじめに
近年、大規模言語モデル(LLM)の進化は目覚ましく、ソフトウェア開発の現場にも大きな変化をもたらしています。特に、自然言語による指示からコードを生成する能力は、開発効率の向上に貢献すると期待されています。しかし、その一方で、LLMが生成したコードにセキュリティ上の脆弱性が潜むリスクも指摘されています。
そこで本稿では、LLMが生成したWebアプリケーションに対して実際にセキュリティ診断を実施し、セキュリティ面での課題を確認します。その上で、どのようにLLMをソフトウェア開発の現場で利活用するべきか考察します。
検証手順
検証は以下の手順で進めました。
1. LLMによるコード生成
OpenAI GPT-4oを使用し、下記のプロンプトを与えてブログアプリケーションのPythonコード(Flask)とHTMLテンプレートを生成させました。GPT-4oを使用した理由としては、高性能なLLMの一つとして広く認知されており、多様なタスクで高い能力を発揮するためとなります。
また、あえてセキュリティに関する要件(入力値の検証、出力のエスケープ、CSRF対策、ファイルアップロードの制限、セキュアな設定など)は明示的に指示しませんでした。これにより、LLMがデフォルトでどの程度セキュアなコードを生成する傾向があるかを確認します。
与えたプロンプト:
PythonのFlaskフレームワークとSQLiteデータベースを使用して、以下の機能を持つブログアプリケーションの基本的なコードを生成してください。
出力には、基本的なHTMLテンプレートも含めてください。
1. 新規アカウント登録機能
2. ログイン機能
3. プロフィール機能(プロフィール画像と自己紹介文)
4. 記事の一覧表示
5. 記事の詳細表示
6. 新しい記事の投稿(タイトルと本文)
7. 記事の編集
8. 記事の削除
2. アプリケーションのデプロイ
- 生成されたコードをローカルの開発環境にデプロイし、Webサーバーを起動しました。
(生成されたコードに修正を加えることなく起動することができました。) - 下図のシンプルなページが表示され、プロンプトで指示した基本的なブログ機能が動作することを確認しました。
3. 脆弱性診断の実施
ツールを使用した自動スキャンおよび手動診断にて脆弱性を確認しました。
-
自動スキャン:
Burp Suiteの自動スキャン機能を用いて、アプリケーション全体を網羅的にスキャンしました。 -
手動診断:
自動スキャンで検出された脆弱性について、実際に悪用可能か、誤検知ではないかを手動で確認しました。また、自動スキャンでは見逃されやすいロジック系の脆弱性がないか、主要な機能を中心に手動でのテストも行いました。
検証結果
GPT-4oが生成したコードに対して脆弱性診断を行った結果、いくつかの脆弱性が検出されました。
以下にその例を示します。
脆弱性例 | リスクレベル | 説明 |
---|---|---|
デバッグモードが有効 | 高 | Flaskのデバッグモードが有効でした。これにより、エラー発生時にソースコードが漏洩すること、条件はあるもののデバッグコンソールからリモートコードの実行が可能でした。 |
ファイルアップロードに関する脆弱性 | 高 | アップロードされたファイルの種類や内容に対する検証が不十分であったため、プロフィール画像として、画像ファイル(JPEG, PNGなど)以外のファイル(例: 悪意のあるスクリプトファイル)もアップロード可能でした。また、ファイル名が一意にならないため、他ユーザーのファイルを上書きすることが可能でした。 |
予測可能なセッション署名鍵の使用 | 高 | セッション管理に使用される署名鍵が予測可能でした。これにより、認証をバイパスし他ユーザーになりすますことが可能でした。 |
CSRF(クロスサイト・リクエスト・フォージェリ) | 中 | 記事の投稿、編集、削除などの重要な操作において、CSRFトークンによる対策が実装されていませんでした。これにより、ログイン中のユーザーが意図しない操作を強制させられる可能性がありました。 |
不十分な入力検証 | 低 | タイトルや本文の文字数制限、特定の文字種の制限などが行われておらず、予期せぬ入力によってエラーが発生したり、他の脆弱性を誘発したりする可能性がありました。また、入力パスワード強度の検証もされておらず、たとえ1文字だとしてもパスワードとして設定することが可能でした。 |
セキュリティ設定の不備 | 低 | セッションクッキーにSecure属性やHttpOnly属性が付与されていないなど、Flaskのデフォルト設定に起因する軽微な設定不備が見られました。 |
一方で、一般的にWebアプリケーションで多く見られるクロスサイトスクリプティング (XSS) や SQLインジェクション といった脆弱性は、今回の診断では検出されませんでした。
これは、以下の要因が考えられます。
-
フレームワーク/テンプレートエンジンのデフォルト機能:
使用したFlaskフレームワークや、テンプレートエンジンであるJinja2には、デフォルトである程度のセキュリティ機能(例: HTMLエスケープによるXSS対策、パラメータ化クエリによるSQLインジェクション対策の推奨)が備わっています。
LLMがこれらの標準的な使い方に沿ったコードを生成した可能性があります。 -
アプリケーションの単純さ:
生成されたアプリケーションが比較的シンプルであり、複雑なデータ処理や動的なコンテンツ生成が少なかったため、脆弱性が入り込む箇所が限定的だった可能性があります。
しかし、重要な点として、GPT-4oはファイルアップロードの検証、CSRF対策、入力検証といった、フレームワークのデフォルト機能だけではカバーされないセキュリティ対策や、セキュアな設定の適用(デバッグモードの無効化や適切な署名鍵の使用、Cookie属性の付与など)を十分に実装しませんでした。
これにより、LLMが暗黙のセキュリティ要件やベストプラクティスを必ずしも理解しているとは限らず、自律的に安全性の高いコードを生成することを前提とした開発は危険であることが示唆されています。
コード上の該当箇所と問題点
ここでは、検出された各脆弱性について、LLMが生成したコードのどの箇所に問題があるのかを詳しく解説します。
LLMによって生成されたバックエンド全体のコードは、以下の通りです。
from flask import Flask, render_template, request, redirect, url_for, session, flash
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename
import sqlite3, os
app = Flask(__name__)
app.secret_key = 'your_secret_key'
app.config['UPLOAD_FOLDER'] = 'static/uploads/'
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
DATABASE = 'blog.db'
def get_db():
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
return conn
def create_tables():
db = get_db()
db.execute("""CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
bio TEXT,
profile_image TEXT
)""")
db.execute("""CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
title TEXT,
content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)""")
db.commit()
@app.route('/')
def index():
db = get_db()
posts = db.execute('SELECT posts.*, users.username FROM posts JOIN users ON posts.user_id = users.id ORDER BY created_at DESC').fetchall()
return render_template('index.html', posts=posts)
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = generate_password_hash(request.form['password'])
db = get_db()
try:
db.execute('INSERT INTO users (username, password) VALUES (?, ?)', (username, password))
db.commit()
flash('Registration successful. Please login.')
return redirect(url_for('login'))
except sqlite3.IntegrityError:
flash('Username already exists.')
return render_template('register.html')
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
db = get_db()
user = db.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
if user and check_password_hash(user['password'], password):
session['user_id'] = user['id']
session['username'] = user['username']
return redirect(url_for('index'))
flash('Invalid credentials.')
return render_template('login.html')
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('index'))
@app.route('/profile', methods=['GET', 'POST'])
def profile():
if 'user_id' not in session:
return redirect(url_for('login'))
db = get_db()
user = db.execute('SELECT * FROM users WHERE id = ?', (session['user_id'],)).fetchone()
if request.method == 'POST':
bio = request.form['bio']
file = request.files['profile_image']
filename = user['profile_image']
if file:
filename = secure_filename(file.filename)
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
db.execute('UPDATE users SET bio = ?, profile_image = ? WHERE id = ?', (bio, filename, session['user_id']))
db.commit()
return redirect(url_for('profile'))
return render_template('profile.html', user=user)
@app.route('/post/<int:post_id>')
def post_detail(post_id):
db = get_db()
post = db.execute('SELECT posts.*, users.username FROM posts JOIN users ON posts.user_id = users.id WHERE posts.id = ?', (post_id,)).fetchone()
return render_template('post_detail.html', post=post)
@app.route('/create', methods=['GET', 'POST'])
def create():
if 'user_id' not in session:
return redirect(url_for('login'))
if request.method == 'POST':
title = request.form['title']
content = request.form['content']
db = get_db()
db.execute('INSERT INTO posts (user_id, title, content) VALUES (?, ?, ?)', (session['user_id'], title, content))
db.commit()
return redirect(url_for('index'))
return render_template('create.html')
@app.route('/edit/<int:post_id>', methods=['GET', 'POST'])
def edit(post_id):
if 'user_id' not in session:
return redirect(url_for('login'))
db = get_db()
post = db.execute('SELECT * FROM posts WHERE id = ?', (post_id,)).fetchone()
if post['user_id'] != session['user_id']:
return redirect(url_for('index'))
if request.method == 'POST':
title = request.form['title']
content = request.form['content']
db.execute('UPDATE posts SET title = ?, content = ? WHERE id = ?', (title, content, post_id))
db.commit()
return redirect(url_for('post_detail', post_id=post_id))
return render_template('edit.html', post=post)
@app.route('/delete/<int:post_id>', methods=['POST'])
def delete(post_id):
if 'user_id' not in session:
return redirect(url_for('login'))
db = get_db()
post = db.execute('SELECT * FROM posts WHERE id = ?', (post_id,)).fetchone()
if post['user_id'] == session['user_id']:
db.execute('DELETE FROM posts WHERE id = ?', (post_id,))
db.commit()
return redirect(url_for('index'))
if __name__ == '__main__':
create_tables()
app.run(debug=True)
デバッグモードが有効
該当コード
if __name__ == '__main__':
create_tables()
app.run(debug=True)
問題点
-
debug=True
により、エラー発生時にフルスタックトレースや環境変数が表示される危険性やリモートコードが実行される危険性があります。
/profile
)
ファイルアップロードに関する脆弱性(該当コード
file = request.files['profile_image']
filename = user['profile_image']
if file:
filename = secure_filename(file.filename)
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
問題点
- MIMEタイプや拡張子の検証がないため、スクリプトや実行可能ファイルなどがアップロード可能になっています。
- ファイル名が一意でないため、他のユーザーの画像を上書き可能になっています。
-
static/
配下に保存されるため、ブラウザから直接アクセス可能で実行もされ得る可能性があります。
予測可能なセッション署名鍵の使用
該当コード
app.secret_key = 'your_secret_key'
問題点
- セッション署名鍵に予想困難な値が使用されていないので、書き換えずにデプロイを行うと脆弱となってしまいます。
/create
, /edit
, /delete
)
クロスサイト・リクエスト・フォージェリ(該当コード(例:/delete
)
@app.route('/delete/<int:post_id>', methods=['POST'])
def delete(post_id):
...
db.execute('DELETE FROM posts WHERE id = ?', (post_id,))
問題点
- リクエストに対するトークンの検証が一切ないため、CSRF攻撃を受けてしまう可能性があります。
/register
, /create
, /edit
など)
不十分な入力検証(該当コード(一例)
username = request.form['username']
password = generate_password_hash(request.form['password'])
title = request.form['title']
content = request.form['content']
問題点
-
username
,title
,content
などに文字数制限や禁止文字チェックが存在しないため、予期せぬ入力により、アプリケーションに不具合が発生する可能性があります。 -
password
に強度チェックが一切行われていないため、ユーザーが強度の弱いパスワードを設定することが可能です。
セキュリティ設定の不備
該当コード
以下の記載がソースコード上にない
app.config['SESSION_COOKIE_SECURE'] = True
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
問題点
- セッションクッキーに対して
Secure
やHttpOnly
,SameSite
のセキュリティに関する設定が未指定となっています。
まとめ
今回の検証にて、最先端のLLMであるGPT-4oが生成したシンプルなWebアプリケーションコードに対しても、ファイルアップロードの不備、CSRF、不十分な入力検証、セキュリティ設定ミスといった複数の脆弱性が含まれる可能性があることが示されました。
この結果から、LLMをWeb開発に利用する際には、その利便性の裏にあるセキュリティリスクを十分に認識し、対策を講じる必要があることがわかります。
これらを踏まえたうえで、LLMをソフトウェア開発で活用していく際には、下記点などを意識すると良いと考えられます。
-
適切なプロンプトの作成:
よりセキュアなコードを生成させるためには、以下のような具体的かつ明確なセキュリティ要件をプロンプトに含めることが重要になる可能性があります。- XSS対策を実装して
- SQLインジェクションを防ぐコードにして
- セッションに使用される署名鍵には、推測困難な暗号学的に十分にランダムな値を設定して
- 入力値は英数字のみ、最大50文字まで受け付けるように検証して
- セッションCookieにはSecure属性とHttpOnly属性を付与して
- 本番環境ではデバッグモードを無効にして
-
知見を持ち合わせた開発者によるコードレビューの実施:
LLMが生成したコードはフレームワークの機能などによって安全な場合もありますが、今回の検証結果のように脆弱性が作り込まれてしまうことが多々あるため、知識のある開発者がコードレビューをすることが重要です。 -
脆弱性診断の実施:
生成されたアプリケーションに対して、自動・手動の両面から脆弱性診断を実施することが重要です。
LLMは開発を効率化する強力なツールです。実際に検証用のコードは数十秒で生成され、修正を加えることなく一通りの機能が動作することが確認できたことから、効率化のためにLLMを実務に取り入れる価値は高いと考えられます。
しかしながら、今回の検証結果が示すように、LLMの出力を鵜呑みにするのではなく、あくまで強力な「開発支援ツール」と位置づけたうえで、様々な視点から評価・修正を行う姿勢が、その恩恵を安全に享受するために求められます。
最後に、ChillStack では現在エンジニアを積極採用中です!
「今は転職する気はないけど、ちょっと興味を持ったので話を聞いてみたい」という方も歓迎ですので、ご興味ある方お待ちしてます!
-
公式 Note
https://note.com/chillstack
Discussion