はてなの AtomAPI に脆弱性あり!?

最近はてなブックマークAtomAPI をいじってて気付いたのですが,はてなの WSSE認証1の実装には脆弱性がありそうな気がします.

WSSEの認証についてはこことそこで引用されてるワーキングドラフトが詳しいのですが,要はHTTPヘッダに次のような情報を埋め込む認証方式のようです.

X-WSSE: UsernameToken Username="ユーザー名", PasswordDigest="パスワードダイジェスト", Nonce="ランダム値", Created="送信日時(ISO8601表記)"

ここで,

PasswordDigest = Base64 ( SHA-1 ( nonce + created + password ) )

です. ※異なる規格もあるようです
サーバ側では,受け取ったヘッダの情報の「username に対応付けられたパスワード」と「nonce」と「created」から PasswordDigest を計算し,受け取ったものと等しくなるか確認することで認証する仕組みです.

WSSEの利点ですが,

  • 盗聴されてもパスワードが流出しないので SSL がつかえないサーバでも安全にデータをやり取りできる
  • リプレイアタックに対処できる
    が大きな利点じゃないかと思います. ※その他の利点についてはこちらを参照のこと

リプレイアタックとは,認証に一度使った情報をそのまま利用すれば認証に成功してしまうという攻撃です.
つまり認証情報をハッシュ化したとしても,そのハッシュ値をそのまま使えば認証できてしまうということです.
リプレイアッタクに対処するため,ワーキングドラフトによれば

For web service providers to effectively thwart replay attacks, three counter measures are recommended:
First, it is recommended that web service providers reject any UsernameToken not using both nonce and creation timestamps.

Second, it is recommended that web service producers provide a timestamp “freshness” limitation, and that any UsernameToken with “stale” timestamps be rejected. As a guideline, a value of five minutes can be used as a minimum to detect, and thus reject, replays.

Third, it is recommended that used nonces be cached for a period at least as long as the timestamp freshness limitation period, above, and that UsernameTokens with nonces that have already been used (and are thus in the cache) be rejected

要は

  • nonce とタイムスタンプ(created)の情報がなければリジェクト
  • 古いタイムスタンプはリジェクト
  • 有効なタイプスタンプ内に使われた nonce は記録しておいて,再利用された場合はリジェクト

ということが推奨されています.
この通りに実装すると,全く同じ内容での認証は成功しないことになります.リプレイアタックに対処するためだから当たり前ですね.

ところが,はてなブックマークAtomAPI では全く同じ内容でも認証できてしまうのです.
今朝使った認証情報を夜に使ってみても認証できてしまいますし,異なるネットワークから認証を試みても認証できてしまいます.
つまり盗聴されてしまったらパスワードが流出しなくてもブックマークを操作し放題です.
HTTPS によるアクセスもできないようなので,API を利用するためにはそのことを頭の隅においた上で使ったほうが良さそうです.

以下にテスト用のPHPプログラムを示します.
$user と $passwd はちゃんとしたものを使ってください.HTTP_REQUESTモジュールが必要です.

<?php
// cf. http://thinkit.co.jp/article/1112/1
require_once 'HTTP/Request.php';

$user = 'はてなID';
$passwd = 'はてなのパスワード';
$url1 = 'ブックマークしたいページのURL1つ目';
$url2 = 'ブックマークしたいページのURL2つ目';

$wsse = make_wsse($user, $passwd);
edit_bookmark($wsse, $url1, '');
edit_bookmark($wsse, $url2, '');

function edit_bookmark($wsse, $url, $cmt, $edit_id = NULL) {
	if ($edit_id) {
		$api_url = 'http://b.hatena.ne.jp/atom/edit/' . $edit_id;
		$link = '';
	} else {
		$api_url = 'http://b.hatena.ne.jp/atom/post';
		$link = '<link rel="related" type="text/html" href="'.$url.'" />';
	}
	$postdata = '<entry xmlns="http://purl.org/atom/ns#"><title>dummy</title>' . $link . '<summary type="text/plain">'.$cmt.'</summary></entry>';
	
	$req = new HTTP_Request();
	$req->addHeader('Accept','application/x.atom+xml, application/xml, text/xml, */*');
	$req->addHeader('Authorization', 'WSSE profile="UsernameToken"');
	echo $wsse,"\n";
	$req->addHeader('X-WSSE',$wsse );
	$req->addHeader('Content-Type', 'application/x.atom+xml');
	$edit_id ? $req->setMethod(HTTP_REQUEST_METHOD_PUT) : $req->setMethod(HTTP_REQUEST_METHOD_POST);
	$req->setURL($api_url);
	$req->addRawPostData($postdata);
	
	if (PEAR::isError($req->sendRequest())) {
		exit('通信エラーが発生しました');
	}
	$code = $req->getResponseCode();
	if ($code == 201) {
		$res = (array) simplexml_load_string($req->getResponseBody());
		$edit_id = substr($res['link'][2]->attributes()->href, 10);
		echo "Edit ID is $edit_id\n";
		echo "ブックマークの追加に成功しました\n";
	} else if ($code == 200) {
		echo "ブックマークの編集に成功しました\n";
	} else {
		echo "エラーが発生しました:コード = ${code}\n";
	}
}

function make_wsse($username, $password) {
     $created = date('Y-m-d\TH:i:s\Z');
     $nonce = pack('H*', sha1(md5(time())));
     $digest = base64_encode(pack('H*', sha1($nonce . $created . $password)));
     $wsse = 'UsernameToken Username="' . $username.'", PasswordDigest="'. $digest .'", Nonce="'. base64_encode($nonce) .'", Created="'.$created.'"';

     return $wsse;
}
?>
  1. このへんを見れば何の略称かわかりそうですけどやっぱりわからないです…