Digest認証の仕組みを検証してみた

  • ksnctfさんの#9 Digest is secure!という問題を解きたく、Digest認証を実装レベルで理解する必要があった
  • やはり、中の値を自分の手で確認するとよく分かる
  • 最終的に何(クライアント側の)と何(サーバ側の)をチェックすることになるのかを理解するのが重要だと思った
  • レスポンスヘッダの値を整理するために"や,で区切る場合に方法によってはダブルクォートが残るので認証に失敗する場合がある
  • レスポンス値の生成用にメソッドを作っておいた

以下を参考にさせて頂きました。
ありがとうございました。
JavaTips 〜アプリケーションサーバ/コンテナ活用編:Tomcatでダイジェスト認証を使う - @IT
shain.blog.conextivo.com

以下、検証用のコード(PHP)

<?php
function h($s) {
    return htmlspecialchars($s, ENT_QUOTES, "UTF-8");
}

/* ※realmを引数に取るのはよろしくないが */
function require_digest_auth($realm) {
    header('HTTP/1.1 401 Unauthorized');
    header(
        'WWW-Authenticate: Digest realm="' .
        $realm .
        '",qop="auth",nonce="' . uniqid() . '",opaque="' .
        md5($realm) . '"'
    );
}

/* ユーザー名 & パスワード & 認証名 */
$user_id = "admin";
$password = "mypass";
$realm = "Restricted area";

/* 初回アクセス時 */
if (empty($_SERVER["PHP_AUTH_DIGEST"])) {
    require_digest_auth($realm);
} else {

    /* パラメータを整理する */
    $data = [];
    $pairs = explode(",", $_SERVER["PHP_AUTH_DIGEST"]);
    foreach ($pairs as $kv) {
        $pair = explode("=", $kv);
        $key = $pair[0];

        /* ※値にダブルクォートが残るので取り除く */
        /*以前、値が一致しなかったときはこれを忘れていた */
        $val = str_replace('"', '', $pair[1]);

        $data[trim($key)] = $val;
    }
}
?>
<!-- デバッグ用 -->
<pre><?php print_r($data); ?></pre>

<?php
/* 表示用の文字列 */
$pad = ") =><br>&nbsp;&nbsp;";

/* hash1 */
$plain1 = sprintf("%s:%s:%s", 
    $user_id, $realm, $password);
$hash1 = md5($plain1);
echo "[hash1]: md5(" . h($plain1) . $pad . 
    h($hash1) . "<br>";

/* hash2 */
$method = $_SERVER["REQUEST_METHOD"];
$uri = $data["uri"];

$plain2 = sprintf("%s:%s", $method, $uri);
$hash2 = md5($plain2);
echo "[hash2]: md5(" . h($plain2) . $pad .
    h($hash2) . "<br>";

/* hash3 */
$nonce = $data["nonce"];
$nc = $data["nc"];
$cnonce = $data["cnonce"];
$qop = $data["qop"];

$plain3 = sprintf("%s:%s:%s:%s:%s:%s",
    $hash1, $nonce, $nc, $cnonce, $qop, $hash2);
$hash3 = md5($plain3);
echo "[hash3]: md5(" . h($plain3) . $pad .
    h($hash3) . "<br>";

/* クライアント&サーバーで生成した値を比較する */
echo "[client response]: " . h($data["response"]) . "<br>";
echo "[server auth value]: " . h($hash3) . "<br>";

/* 認証を行う */
if ($data["response"] === $hash3) {
    echo "Authorization success<br>";
} else {
    echo "Authorization failed<br>";
}

/* デバッグ用(必ず認証に失敗する) */
require_digest_auth($realm);

/* レスポンス値の計算用メソッドを作成 */
function digest_auth_response(
    $user, $realm, $pass,    /* hash1 */
    $req_method, $uri,    /* hash2 */
    $nonce, $nc, $cnonce, $qop    /* (part of) hash3 */
    ) {
    $hash1 = md5(sprintf("%s:%s:%s",
        $user, $realm, $pass));
    $hash2 = md5(sprintf("%s:%s",
        $req_method, $uri));
    $hash3 = md5(sprintf("%s:%s:%s:%s:%s:%s",
        $hash1, $nonce, $nc, $cnonce, $qop, $hash2));
    return $hash3;
}

/* テスト */
echo "[test]: " . digest_auth_response(
    $user_id, $realm, $password,
    "GET", "/digest_auth/a.php",
    $data["nonce"], $data["nc"], $data["cnonce"],
    $data["qop"]) . "<br>";

/*
初回アクセス時(サーバからのレスポンス)
got information↓
WWW-Authenticate: 
  Digest realm="Restricted area",
  qop="auth",
  nonce="57e2523e5c05c",
  opaque="cdce8a5c95a1427d74df7acbf41c9ce0"

※nonce, opaqueは16進 or Base64

ログイン情報入力時(クライアントからの送信値)
username="a",
realm="Restricted area",
nonce="57e4e3d67cc3f",
uri="/digest_auth/a.php", response="25723ce9f33d91cf8d65edd843af8d69", opaque="cdce8a5c95a1427d74df7acbf41c9ce0", qop=auth, 
nc=00000001, 
cnonce="78ebe03c511e4f8f"

パラメータの意味
realm...認証名
nonce...サーバー側で生成した乱数
response...クライアント側で生成したハッシュ値
opaque...サーバー側で生成した乱数?
qop...保護レベル(Quality of protection)
nc...クライアントからのリクエスト回数(16進数)
cnonce...クライアント側で生成した乱数

クライアント側で生成するのは
cnonceと、それを一部に用いたresponse
最終的にresponse値をサーバ側で生成したものと比較する
サーバ側も値の計算にcnonceを用いるため、
クライアントからcnonceを受け取る必要がある
*/
?>

Rubyでも作っておきます(足りないパラメータを教えてくれるようにした)

require "digest/md5"

def digest_auth_response(**args)
  t = {
    user: false, realm: false, pass: false,    # hash1
    method: false, uri: false,    # hash2
    nonce: false, nc: false, cnonce: false, qop: false    # hash3
  }
  t.update(args)
  invalids = []
  t.each_pair do |k, v|
    if v === false
      invalids << k
    end
  end
  if invalids.length > 0
    raise ArgumentError,
      "#{invalids.join ' & '} values are missing"
  end
  hash1 = Digest::MD5.hexdigest(
    "%s:%s:%s" % [t[:user], t[:realm], t[:pass]])
  hash2 = Digest::MD5.hexdigest(
    "%s:%s" % [t[:method], t[:uri]])
  Digest::MD5.hexdigest(
    "%s:%s:%s:%s:%s:%s" % [
      hash1, t[:nonce], t[:nc], t[:cnonce], t[:qop], hash2
    ])
end

# テスト
res = digest_auth_response(
  user: "admin",
  realm: "Restricted area",
  pass: "mypass",
  method: "GET",
  uri: "/digest_auth/a.php",
  nonce: "57e52f1800bd9",
  nc: "00000001",
  cnonce: "7cbb31e4603de8a70ce700b356accecf",
  qop: "auth"
)
puts "response: %s" % res

The best way to understand specifications
is to write the code yourself from scratch (from the beginning).
(仕様を理解しようと思ったら、自分の手でコードを書くのが一番)