【Common Lisp】
外部入力に予断を持つのは脆弱性のもと
(readとscanf)

  • Common Lispのread関数はGCがメモリを管理するため、CのscanfようなバッファオーバーフローやUse-after-freeの問題は通常気にしないで済みます。
  • ただし、Common Lispのreadは、単なる数値パーサではなく、Lispオブジェクトのリーダーで数値のつもりで呼んでもシンボルやリストも受け入れます。
  • readした結果をそのまま評価すると、SQLインジェクションと似た構造になり、外部入力から任意のコードが実行されることがあります。
  • 特に注意が必要なのは、evalを使わなくても、#.(read-time evaluation、読み取り時評価)によって、readの途中でもコードが実行されること。
  • 外部入力にreadを使うなら*read-eval*をnilにする必要がある。
【Common Lisp】<br class="chiilabo-br is-on">外部入力に予断を持つのは脆弱性のもと<br class="chiilabo-br is-on">(readとscanf)

関連記事

1. Lispのメモリ安全性とGC

C言語由来のメモリ脆弱性をコンパイル時に防ぐ、としてRustが注目されています。

バッファオーバーフロー、Use-after-free、二重解放といったメモリアクセスの問題を、Rustは所有権・借用・ライフタイムの仕組みで静的に検出します1
危ないコードは通さない、という方針です。

では、Common Lispはどこに位置づけられるのでしょうか。

Common Lispは、GCがメモリを管理します。
GCは「garbage collector」の略で、不要になったメモリを自動的に回収する仕組みです。
通常のLispコードでは生ポインタを直接扱わないため、Cで典型的なバッファオーバーフローやUse-after-freeは起きにくい設計になっています。

ただし、Rustと同じような意味で「安全」というわけではありません。
というのも、たとえばSBCLでは最適化宣言で安全性を下げられます。

(declare (optimize (speed 3) (safety 0)))Code language: Lisp (lisp)

パフォーマンスを優先すると、型チェックや境界チェックが省略されるので、間違った型宣言や配列外アクセスが未定義に近い挙動を生む可能性があります2
また、Cライブラリとの接続に使うFFI(Foreign Function Interface)には、C側の危険性をそのまま持ち込む面もあります3

三者の位置づけはこうです。

  • C:
    低レベルで自由。
    メモリ破壊系の脆弱性が起きやすい。
  • Rust:
    低レベル性能を保ちつつ、メモリ安全性をコンパイル時に保証する。
  • Common Lisp:
    通常の高水準コードではメモリ破壊系の脆弱性は起きにくい。
    ただし、最適化宣言・FFI・処理系依存機能で安全性を崩せる。

つまり、Lispは「Rustのように厳格に安全を証明する言語」ではなく、「そもそも生メモリ操作を普段しないので、その種の事故が表に出にくい言語」と言えます。

1.1. scanfのバッファオーバーフローとread

C言語には、バッファ・オーバーフローの入口になりやすい典型的な機能の一つにscanfがあります。

char buf[16];
scanf("%s", buf);  // 長い入力でバッファを超えるCode language: Arduino (arduino)

%sは入力の長さを確認しないため、たとえば 16バイトを超える文字列を渡すと後続するメモリを破壊的に書き込んでしまいます。
これが任意コード実行につながることがあるわけです4

Common Lispは、競技プログラミング環境では、次のように整数を読むのはよくある書き方です。

(let ((n (read)))
  ...)Code language: Lisp (lisp)

Common Lispのreadには、このタイプのメモリ破壊はありません。
GCと動的なオブジェクト管理があるためです。

ただし、readは整数だけ読む関数ではありません。
Lispオブジェクトのリーダーです。
数値だけでなく、シンボル、リスト、文字列なども読めます5

(read-from-string "123")      ;=> 123
(read-from-string "abc")      ;=> ABC というシンボル
(read-from-string "(+ 1 2)")  ;=> (+ 1 2) というリストCode language: Lisp (lisp)

たとえば、(+ 1 2)を読むとリストを作ります。
「数値だけ来るはず」という前提で書いたコードに、想定外のオブジェクトが入ってくる可能性があります。

2. SQLインジェクションとeval

Common Lispには、evalがあり、リストをコードとして評価できます。

つまり、readが読んだオブジェクトをevalすると、外部入力をコードとして実行できます。

;; 危険な例
(eval (read-from-string user-input))Code language: Lisp (lisp)

もし、user-input"(+ 1 2)"なら3を返すだけです。
しかし、任意のコードが実行できるので、

"(delete-file \"important.txt\")"Code language: Lisp (lisp)

を渡されると、ファイルを削除します。
ユーザーの入力が「プログラム」になってしまうのです。

これは、SQLインジェクションと構造が同じです6

-- SQLインジェクション
SELECT * FROM users WHERE name = '入力値'
-- 入力値に SQL断片を混ぜると命令が書き換わるCode language: SQL (Structured Query Language) (sql)

どちらも、データとコードの境界が崩れることで起きます。

2.1. なぜLispではSQLほど問題になりにくいか

ただし、SQLインジェクションが問題として多いのは、理由があります。

Webアプリの通常処理として「ユーザー入力をクエリに混ぜる」場面が頻繁にあるからです。
昔ながらの文字列連結でそのままクエリを組み立てると、データと命令の境界が崩れやすくなります。

一方、Lispでは通常のアプリケーション処理でユーザー入力をLispコードとして実行する必要はそんなにありません。
evalが自然に現れるのは、REPL、マクロの実験、設定DSL、プラグイン機構など「プログラムを書くためのプログラム」に寄った用途です。

フォーム入力、ファイル名、数値、JSONなどはデータとして処理し、通常は evalを呼ぶ必要がありません。

2.2. readの途中でコードが走る——#.

それでも、注意が必要なのが、#.の存在です。

これはread-time evaluation(読み取り時評価)と呼ばれる機能で、evalを明示しなくてもreadの途中でコードが実行されます。

(read-from-string "#.(+ 1 2)")
;=> 3  リストではなく、計算結果が返るCode language: Lisp (lisp)

この読み取り時評価では、副作用も起こせます。

(read-from-string "#.(delete-file \"important.txt\")")
;; read-from-string を呼んだ時点でファイルが削除されるCode language: Lisp (lisp)

evalを一切呼んでいないのに、文字列を読む処理でもコードが実行できるのです。
これは、Pythonでのデシリアライズの途中でコードが動くunsafe deserializationに近い性質で、SQLインジェクションより気づきにくいかもしれません7

2.3. なぜこんな機能があるのか

#.はもともと、信頼できるLispソースコード内部で使うための機能です。

読み込み時に定数を計算して埋め込む、配列をコンパイル時に生成しておくといった用途です。

;; ソースコード内での正当な使い方
(defconstant +buffer-size+
  #.(* 1024 1024))  ; 読み込み時に 1048576 に展開される

(defparameter *crc-table*
  #.(make-crc-table))  ; コンパイル時にテーブルを生成Code language: Lisp (lisp)

信頼したソースを書く人向けの機能であり、外部入力向けではありません8

2.4. readが使える場面、使えない場面

入力が信頼できる環境では、readは強力な道具です。

AtCoderのような競技プログラミング環境では、入力は整形された数値列で、不正な入力は来ないと保証されています。
(read)で整数を読むのは自然な書き方です。

;; AtCoderの典型的な入力読み取り
(defun main ()
  (let* ((n (read))
         (a (loop repeat n collect (read))))
    ...))Code language: Lisp (lisp)

REPLや設定ファイルの読み取り、Emacsの設定、DSLの実装なども、書く人が信頼できる前提で動いています。

一方、Webサーバーや公開CLIなど不特定多数の入力を受ける場所では、readをそのまま使うのは危険です。

入力が信頼できる場合(競プロ、REPL、開発環境)
  (read) で読んでよい

不特定の外部入力を受ける場合(Webサーバー、公開CLI)
  parse-integer、専用パーサ、JSONなどを使う
  どうしても read を使うなら *read-eval* を nil にしてから型を検査するCode language: JavaScript (javascript)

3. 外部入力を安全に読む

外部入力にreadを使うなら、まず*read-eval*をnilにします。

*read-eval*は読み取り時評価を許すかどうかの動的変数です9

;; 最低限の対策
(let ((*read-eval* nil))
  (read-from-string user-input))Code language: Lisp (lisp)

これで#.は禁止されます。
ただし、「完全安全」にはなりません。
巨大なリストや巨大な整数を読ませてメモリを消費させる、想定外の型を渡すといった問題は残ります。
読み込んだ後に、許可する型や形を検査する必要があります。

3.1. parse-integerとの比較

整数だけを読みたいなら、readよりparse-integerの方が意図が明確です。

;; read: 何でも受け入れる
(read-from-string "(delete-file \"important.txt\")")
;=> (DELETE-FILE "important.txt")  リストとして読まれる

;; parse-integer: 整数以外はエラー
(parse-integer "(delete-file \"important.txt\")")
;=> Error: not a valid integerCode language: Lisp (lisp)

parse-integerは文字列を整数として解釈するだけで、Lispオブジェクトを生成しません。
入力の形式を限定するという意味で、scanfの%dに近い感覚です。

複数の値を読むなら、空白で分割してそれぞれ変換します10

(mapcar #'parse-integer
        (cl-ppcre:split "\\s+" line))Code language: Lisp (lisp)

3.2. JSONや計算式のパース

構造化データにはJSONなど専用フォーマットを使うのが安全です11

;; cl-json などのライブラリを使う
(cl-json:decode-json-from-string user-input)Code language: Lisp (lisp)

計算式を受け取りたい場合は、evalせずに許可した演算子だけを処理します。

(defun safe-calc (expr)
  (destructuring-bind (op a b) expr
    (case op
      (+ (+ a b))
      (- (- a b))
      (* (* a b))
      (/ (/ a b))
      (otherwise (error "Unsupported operator: ~a" op)))))

;; 許可した形だけ使える
(safe-calc '(+ 1 2))  ;=> 3

;; 任意の関数呼び出しは通らない
(safe-calc '(delete-file "important.txt"))
;=> Error: Unsupported operatorCode language: Lisp (lisp)

4. まとめ

Common Lispがメモリ安全であることは確かです。

しかし、外部入力をreadに渡した瞬間に、「数値を読む」つもりが「Lispオブジェクトを生成する」操作に変わります。
#.が有効なままなら、「コードを実行する」ところまで行きます。

Cのバッファオーバーフローとは種類が違いますが、「入力を信じすぎる」という構造は共通しています12

  1. Rustの所有権システムでは、各値には必ず一つの所有者があり、所有者がスコープを抜けると自動的にメモリが解放される。借用チェッカーがコンパイル時に参照の有効性を検証するため、ダングリングポインタやデータ競合がコンパイル段階で検出される。 – The Rust Programming Language – Understanding Ownership
  2. SBCLのsafety=0では型宣言が無条件に信頼され、引数カウントチェックと配列境界チェックも無効になる。ヒープが破壊される可能性があると公式マニュアルに明記されている。 – SBCL User Manual – Compiler Policy
  3. Common LispでのFFIの実質的な標準ライブラリはCFFIで、複数のLisp処理系で動くポータブルなインターフェースを提供している。 – CFFI – The Common Foreign Function Interface
  4. バッファオーバーフローを悪用した最初の記録された攻撃は1988年のMorrisワームで、Unixのfingerサービスの脆弱性が使われた。現在もOWASPが管理するCWE-120として分類されている。 – Buffer Overflow – OWASP Foundation
  5. CLHS(Common Lisp HyperSpec)ではreadを「リーダーアルゴリズムを使ってストリームから一つのLispオブジェクトを読み取り返す関数」と定義している。read-from-stringは文字列を入力としたreadの変形で、最初のLispオブジェクトを読んだ位置も返す。 – CLHS: Function READ
  6. SQLインジェクションはOWASP Top 10の「A03:2021-Injection」カテゴリに含まれ、2021年版では3位(2025年版では5位)に位置づけられている。同カテゴリには94%のアプリケーションでなんらかのインジェクション検査が行われたと報告されている。 – A03 Injection – OWASP Top 10:2021
  7. Pythonのpickleモジュールも同種の問題を持つ。pickle.loads()は復元処理中に任意のPythonコードを実行できるため、Pythonの公式ドキュメントは「信頼できないソースからのデータを絶対にunpickleしないこと」と明記している。 – pickle – Python object serialization
  8. #.はANSI Common Lispの仕様に含まれ、CLHSのSection 2.4.8.6(Sharp-Dot)として定義されている。*read-eval*がfalseのときはreader-errorを通知することも規定されている。 – CLHS: Section 2.4.8.6 Sharpsign Dot
  9. ANSI Common Lispの仕様では*read-eval*の初期値はtrueと定義されている。nilに設定すると#.のリーダーマクロはreader-errorを通知する。 – CLHS: Variable READ-EVAL
  10. cl-ppcreはDr. Edi Weitzが開発したPortable Perl-compatible Regular Expression libraryの略称で、BSDライセンスで公開されている。ANSI準拠のすべてのCommon Lisp処理系でポータブルに動作する。 – CL-PPCRE – Portable Perl-compatible regular expressions for Common Lisp
  11. Common LispのJSONライブラリとしてはcl-jsonのほか、yason(シンプルなAPI)やjonathan(高速処理向け)なども利用されている。Quicklispから導入できる。 – Awesome-cl – JSON libraries
  12. CLHSのperformance cookbookでも、外部入力にreadを使う場合は*read-eval*をnilにした上で型を検査することを推奨している。 – Common Lisp Cookbook – Input/Output