概要
やや物騒なタイトルから始まりましたが、SQLインジェクションを体験できる記事になります!
SQLインジェクションを解説した記事は多くあリますが、リポジトリを公開し簡単にSQLインジェクションを体験できる記事がなかったため、執筆しました。リポジトリを公開しているので、実際に体験できます。また、本記事では、実際に体験しどのような危険性があるのか、またその対策について考えてみました。
簡単なユーザー登録画面とログイン画面を作成しています。
以下がリポジトリです。
SQLインジェクションとは
そもそも、インジェクション(Injection)とは、入力フォームなどの文字列の入力を受け付けるプログラムに対し、不正な文字列を入力することでデータの改ざんや詐取を行うサイバー攻撃のひとつです1。
その一つであるSQLインジェクションとは、アプリケーションの脆弱性を利用し、アプリケーションが想定しないSQL文(データベースへの命令文)を実行させることで、データベースを不正に操作する攻撃方法のことです2。 つまり、入力フォームなどにSQL文を含めた文字列を入力して、不正にSQL文を実行することです。実際に、webアプリケーションへの攻撃の約66%がSQLインジェクションが原因であるという調査があります3。
他にもインジェクションには、JavaScriptインジェクション、プロンプトインジェクションなどがあります。
起動方法
以下のリポジトリをcloneします。
その後、プロジェクト配下で、以下を実行しコンテナをビルド、起動します。
make build
make up
もし、Rancher Desktopを使用していてPermission deniedのエラーが出る場合は以下をご確認ください。
https://qiita.com/tra_/items/73d3ae07a645fd5b0f36
以下でアクセスできます。
http://localhost:8000/
ソースコード
今回、サーバーはPythonのFlask、データベースはPostgreSQLを採用しています。
本来、パスワードはハッシュ化などを施して保存されるべきですが、簡単のため実装していません。また、ログインセッションの保持なども行いません。
以下でテーブル定義をします。
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL
);
以下はユーザー登録画面です。フォームから名前とパスワードを受け取って、SQL文を用いてDBに保存しています。
@app.route("/signup", methods=["GET", "POST"])
def signup():
if request.method == "POST":
name, password = request.form["name"], request.form["password"]
with get_connection() as conn, conn.cursor() as cur:
cur.execute(f"INSERT INTO users (name, password) VALUES ('{name}', '{password}')")
conn.commit()
return redirect("/")
else:
return render_template("signup.html")
そして、以下がログイン画面です。
フォームから名前とパスワードを受け取り、一致するユーザーがいた場合は、マイページへ遷移し、それ以外は、「Invalid username or password」と表示します。
@app.route("/", methods=["GET", "POST"])
def signin():
if request.method == "POST":
name, password = request.form["name"], request.form["password"]
with get_connection() as conn, conn.cursor() as cur:
cur.execute(f"SELECT * FROM users WHERE name = '{name}' AND password = '{password}'")
user = cur.fetchone()
if user:
return redirect("/mypage")
else:
flash("Invalid username or password", "flash-error")
return redirect("/")
else:
return render_template("signin.html")
以下で http://localhost:8000/admin にアクセスすると、DBの中身が見れるようにしてあります。
@app.route("/admin")
def admin():
with get_connection() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM users")
users = cur.fetchall()
return render_template("admin.html", users=users)
登録からログインまで
コンテナ起動後に http://localhost:8000/signup にアクセスして、ユーザ名をuser1
、パスワードをpass1
で登録します。
http://localhost:8000/admin にアクセスすると、ユーザが登録されていることがわかります。
次に、http://localhost:8000/ にアクセスして、先ほど登録したユーザ名とパスワードでログインします。
ユーザ名とパスワードが一致すると、ログインが成功して、マイページに遷移されます。
脆弱性
先ほどのソースコードでは、実行するSQL文に対して、単なる文字列結合が行われており、ユーザーが任意のSQLを実行することができてしまいます。それを利用して、不正にログインをしてみます。例えば、パスワードを知らない悪意のある他者が、以下のパスワードでログインを試みます。ユーザ名をuser1
、パスワードを' OR 1=1; --
にして入力します。
すると、
cur.execute(f"SELECT * FROM users WHERE name = '{name}' AND password = '{password}'")
の部分で文字列結合が行われ以下のクエリが実行されます。
SELECT * FROM users WHERE name = 'user1' AND password = '' OR 1=1; -- '"
このとき、1=1は必ずtrueになるため、全件取得されてしまいます。一致するレコードが存在するかどうかでログイン判定を行なっているため、パスワードが不明でも不正にログインできてしまいます。最後の--
によりそれ以降をコメントアウトしています。これがおそらく一番有名なSQLインジェクションの例です。検索エンジンのサジェスト候補にも出てきます。
他にも、ユーザー登録画面でユーザ名をuser2
、パスワードを以下のように入力します。
pass2'); INSERT INTO users (name, password) VALUES ('Fake User', 'Fake Password'); --
すると、文字列結合が行われ以下のクエリが実行されます。
INSERT INTO users (name, password) VALUES ('user2', 'pass2'); INSERT INTO users (name, password) VALUES ('Fake User', 'Fake Password'); -- ')
そして、user2、pass2という正規のユーザー以外に、Fake User、Fake Passwordという不正なユーザーが作成されてしまいます。
対策
SQLインジェクションの対策方法として、2つ紹介します。
1. プレースホルダーを使用する
以下のように書き換えて文字列の直接代入を回避します。
- cur.execute(f"SELECT * FROM users WHERE name = '{name}' AND password = '{password}'")
+ cur.execute("SELECT * FROM users WHERE name = %s AND password = %s", (name, password))
- cur.execute(f"INSERT INTO users (name, password) VALUES ('{name}', '{password}')")
+ cur.execute("INSERT INTO users (name, password) VALUES (%s, %s)", (name, password))
プレースホルダーにより、ユーザーからの入力データは自動的にエスケープされて、SQLコマンドの一部として扱われなくなります。よって、SQLインジェクションのリスクが防げます。
2. ORMを使用する
ORM(Object-Relational Mapping)は、データベースとアプリケーションの間でデータのやり取りを行う際にSQL文を自動生成し、開発者がSQL文を直接書く必要がありません。そのため、SQLインジェクションのリスクを軽減できます。例えば、FlaskでSQLAlchemyを使用する場合、次のようにしてSQLをエスケープし、安全なデータベース操作を行うことができます。
以下でテーブルを定義します。
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False)
password = db.Column(db.String(255), nullable=False)
以下が、ユーザ登録画面と、ログイン画面です。
@app.route("/", methods=["GET", "POST"])
def signin():
if request.method == "POST":
name, password = request.form["name"], request.form["password"]
user = User.query.filter_by(name=name, password=password).first()
if user:
return redirect("/mypage")
else:
flash("Invalid username or password", "flash-error")
return redirect("/")
else:
return render_template("signin.html")
@app.route("/signup", methods=["GET", "POST"])
def signup():
if request.method == "POST":
name, password = request.form["name"], request.form["password"]
user = User(name=name, password=password)
db.session.add(user)
db.session.commit()
return redirect("/")
else:
return render_template("signup.html")
ORMを使用することでSQLを直接記述することがないので、SQLインジェクションを防ぐことができます。
リポジトリのORMブランチにORMを使用したバージョンのコードがあります。
まとめ
この記事では、SQLインジェクションの脆弱性を体験し、SQL文の文字列結合による危険性と、不正な操作がどのように行われるかについて書きました。
対策として、プレースホルダーの使用やORMの導入が有効であり、これによりセキュリティを強化できます。
-
インジェクションとは - 損保ジャパン 日本サイバーセキュリティ・アナライジス株式会社 ↩
-
SQLインジェクションとは - 損保ジャパン 日本サイバーセキュリティ・アナライジス株式会社 ↩
-
国内からの攻撃増加 手法は SQLインジェクション最多 - ScanNetSecurity ↩