今すぐできる、Webサイトへの2要素認証導入と2要素認証のTOTPとHOTP、どちらがより安全か?で紹介したGoogleAuthenticatorですが、ソースコードを確認ところタイミング攻撃に脆弱でした。Pull Requestを後で送る予定ですが、利用される場合は脆弱性を修正してから使ってください。
タイミング攻撃とは?
タイミング攻撃とは、発熱、ノイズ、時間差、データサイズ(特に圧縮)などの副作用を用いたサイドサイチャネル攻撃の一種です。アルゴリズムなどの脆弱性を直接攻撃するのではなく、時間差などを統計的に処理することにより目的を達成(攻撃)することができます。
タイミング攻撃ではレスポンス時間の微妙な長さの違いを統計的に処理し、目的の秘密情報を盗み取ります。OTPでは数字しか使っておらず、桁数も6桁しかありません。脆弱な場合、タイミング攻撃がとても有効です。
問題のメソッド
GoogleAuthenticatorはOTPのチェックにverifyCodeメソッドを利用します。以下がそのコードです。
/**
* Check if the code is correct. This will accept codes starting from $discrepancy*30sec ago to $discrepancy*30sec from now
*
* @param string $secret
* @param string $code
* @param int $discrepancy This is the allowed time drift in 30 second units (8 means 4 minutes before or after)
* @param int|null $currentTimeSlice time slice if we want use other that time()
* @return bool
*/
public function verifyCode($secret, $code, $discrepancy = 1, $currentTimeSlice = null)
{
if ($currentTimeSlice === null) {
$currentTimeSlice = floor(time() / 30);
}
for ($i = -$discrepancy; $i <= $discrepancy; $i++) {
$calculatedCode = $this->getCode($secret, $currentTimeSlice + $i);
if ($calculatedCode == $code ) {
return true;
}
}
return false;
}
問題となる部分は
if ($calculatedCode == $code ) {
です。$calcuratedCodeと$codeは文字列です。このため、一文字一文字比較するため一文字ずつ間違っている場合と合っている場合の処理時間が異なります。非常に微妙な違いですが、一文字の正しい文字は数千リクエストもあれば検出可能です。※
6桁の数字なので単純なブルートフォース(総当たり)攻撃なら確実に秘密のOTPを盗むには100万リクエスト必要です。しかし、タイミング攻撃を利用すると数万リクエストもあれば盗めることになります。
HOTPの一時パスワードは使用しない限り新しいパスワードに更新されません。HOTPであればリスクがとても高い状態になります。
幸い(?)な事にGoogleAuthenticatorはTOTPしかサポートしていないので、タイミング攻撃に脆弱なコードの影響はHOTPより小さいです。
※ バイト単位で比較されるかはlibcのmemcmp()の実装によります。
コードの修正
タイミング攻撃に対する脆弱性は秘密情報をタイミング攻撃な脆弱な方法で”一文字ずつ”比較していることが原因です。
PHP 5.6以降であれば
if ($calculatedCode == $code ) {
をタイミングセーフな文字列比較関数であるhash_equals関数を使えば安全です。
if (hash_equals($calculatedCode, $code)) {
$calculatedCode、$code共に6桁の整数です。PHP 5.6未満であれば整数として比較すればタイミングセーフになります。
$code = (int)$code;
$calculatedCode = (int)$calculatedCode;
// Make sure $calculatedCode is non-zero.
if ($calculatedCode && $calculatedCode === $code)) {
もう少し防御的に$codeが意図した長さであるか確認した方が良いので、文字列の長さもチェックして以下のようにします。
if (strlen($secret) != 6) {
return false;
}
for ($i = -$discrepancy; $i <= $discrepancy; $i++) {
$calculatedCode = $this->getCode($secret, $currentTimeSlice + $i);
$code = (int)$code;
$calculatedCode = (int)$calculatedCode;
if ($calculatedCode && $calculatedCode == $code ) {
return true;
}
}
まとめ
ソースコード検査はこのように内部仕様の問題を検出するにはとても効果的です。ソースコード検査ツールは多くありますが、このようなタイミング攻撃に対する脆弱性は検出できません。
弊社もソースコード検査を行っています。ソースコード検査が必要な場合はお問い合わせください。
参考:gitのglibcのmemcmp()は以下のような感じで実装されています。このブログによると2010年の段階では多くプラットフォームがタイミング攻撃に脆弱です。Linux 64bit環境memcmp()はバイト単位でタイミングに対して脆弱ではなかったようです。64bit環境のLinuxユーザーは神経質にならなくても良いでしょう。BSD系では別のアプローチで明示的にタイミングセーフなメモリ比較関数が用意されてる物があります。これは恐らく、64bit環境でも8バイト単位に分割してタイミング攻撃ができ、これも脆弱なのでいっそメモリ比較全体をタイミングセーフにしてしまうべき(この方が安全)、という考え方だと思います。環境によって影響は異りますが、認証などクリティカルなコードを書く開発者は今後もタイミング攻撃脆弱性に注意する必要があります。
/* memcmp_common_alignment -- Compare blocks at SRCP1 and SRCP2 with LEN `op_t'
objects (not LEN bytes!). Both SRCP1 and SRCP2 should be aligned for
memory operations on `op_t's. */
static int
memcmp_common_alignment (srcp1, srcp2, len)
long int srcp1;
long int srcp2;
size_t len;
{
op_t a0, a1;
op_t b0, b1;
switch (len % 4)
{
default: /* Avoid warning about uninitialized local variables. */
case 2:
a0 = ((op_t *) srcp1)[0];
b0 = ((op_t *) srcp2)[0];
srcp1 -= 2 * OPSIZ;
srcp2 -= 2 * OPSIZ;
len += 2;
goto do1;
case 3:
a1 = ((op_t *) srcp1)[0];
b1 = ((op_t *) srcp2)[0];
srcp1 -= OPSIZ;
srcp2 -= OPSIZ;
len += 1;
goto do2;
case 0:
if (OP_T_THRES <= 3 * OPSIZ && len == 0)
return 0;
a0 = ((op_t *) srcp1)[0];
b0 = ((op_t *) srcp2)[0];
goto do3;
case 1:
a1 = ((op_t *) srcp1)[0];
b1 = ((op_t *) srcp2)[0];
srcp1 += OPSIZ;
srcp2 += OPSIZ;
len -= 1;
if (OP_T_THRES <= 3 * OPSIZ && len == 0)
goto do0;
/* Fall through. */
}
do
{
a0 = ((op_t *) srcp1)[0];
b0 = ((op_t *) srcp2)[0];
if (a1 != b1)
return CMP_LT_OR_GT (a1, b1);
do3:
a1 = ((op_t *) srcp1)[1];
b1 = ((op_t *) srcp2)[1];
if (a0 != b0)
return CMP_LT_OR_GT (a0, b0);
do2:
a0 = ((op_t *) srcp1)[2];
b0 = ((op_t *) srcp2)[2];
if (a1 != b1)
return CMP_LT_OR_GT (a1, b1);
do1:
a1 = ((op_t *) srcp1)[3];
b1 = ((op_t *) srcp2)[3];
if (a0 != b0)
return CMP_LT_OR_GT (a0, b0);
srcp1 += 4 * OPSIZ;
srcp2 += 4 * OPSIZ;
len -= 4;
}
while (len != 0);
/* This is the right position for do0. Please don't move
it into the loop. */
do0:
if (a1 != b1)
return CMP_LT_OR_GT (a1, b1);
return 0;
}
static int memcmp_not_common_alignment (long, long, size_t) __THROW;
/* memcmp_not_common_alignment -- Compare blocks at SRCP1 and SRCP2 with LEN
`op_t' objects (not LEN bytes!). SRCP2 should be aligned for memory
operations on `op_t', but SRCP1 *should be unaligned*. */
static int
memcmp_not_common_alignment (srcp1, srcp2, len)
long int srcp1;
long int srcp2;
size_t len;
{
op_t a0, a1, a2, a3;
op_t b0, b1, b2, b3;
op_t x;
int shl, shr;
/* Calculate how to shift a word read at the memory operation
aligned srcp1 to make it aligned for comparison. */
shl = 8 * (srcp1 % OPSIZ);
shr = 8 * OPSIZ - shl;
/* Make SRCP1 aligned by rounding it down to the beginning of the `op_t'
it points in the middle of. */
srcp1 &= -OPSIZ;
switch (len % 4)
{
default: /* Avoid warning about uninitialized local variables. */
case 2:
a1 = ((op_t *) srcp1)[0];
a2 = ((op_t *) srcp1)[1];
b2 = ((op_t *) srcp2)[0];
srcp1 -= 1 * OPSIZ;
srcp2 -= 2 * OPSIZ;
len += 2;
goto do1;
case 3:
a0 = ((op_t *) srcp1)[0];
a1 = ((op_t *) srcp1)[1];
b1 = ((op_t *) srcp2)[0];
srcp2 -= 1 * OPSIZ;
len += 1;
goto do2;
case 0:
if (OP_T_THRES <= 3 * OPSIZ && len == 0)
return 0;
a3 = ((op_t *) srcp1)[0];
a0 = ((op_t *) srcp1)[1];
b0 = ((op_t *) srcp2)[0];
srcp1 += 1 * OPSIZ;
goto do3;
case 1:
a2 = ((op_t *) srcp1)[0];
a3 = ((op_t *) srcp1)[1];
b3 = ((op_t *) srcp2)[0];
srcp1 += 2 * OPSIZ;
srcp2 += 1 * OPSIZ;
len -= 1;
if (OP_T_THRES <= 3 * OPSIZ && len == 0)
goto do0;
/* Fall through. */
}
do
{
a0 = ((op_t *) srcp1)[0];
b0 = ((op_t *) srcp2)[0];
x = MERGE(a2, shl, a3, shr);
if (x != b3)
return CMP_LT_OR_GT (x, b3);
do3:
a1 = ((op_t *) srcp1)[1];
b1 = ((op_t *) srcp2)[1];
x = MERGE(a3, shl, a0, shr);
if (x != b0)
return CMP_LT_OR_GT (x, b0);
do2:
a2 = ((op_t *) srcp1)[2];
b2 = ((op_t *) srcp2)[2];
x = MERGE(a0, shl, a1, shr);
if (x != b1)
return CMP_LT_OR_GT (x, b1);
do1:
a3 = ((op_t *) srcp1)[3];
b3 = ((op_t *) srcp2)[3];
x = MERGE(a1, shl, a2, shr);
if (x != b2)
return CMP_LT_OR_GT (x, b2);
srcp1 += 4 * OPSIZ;
srcp2 += 4 * OPSIZ;
len -= 4;
}
while (len != 0);
/* This is the right position for do0. Please don't move
it into the loop. */
do0:
x = MERGE(a2, shl, a3, shr);
if (x != b3)
return CMP_LT_OR_GT (x, b3);
return 0;
}
int
MEMCMP (s1, s2, len)
const __ptr_t s1;
const __ptr_t s2;
size_t len;
{
op_t a0;
op_t b0;
long int srcp1 = (long int) s1;
long int srcp2 = (long int) s2;
op_t res;
if (len >= OP_T_THRES)
{
/* There are at least some bytes to compare. No need to test
for LEN == 0 in this alignment loop. */
while (srcp2 % OPSIZ != 0)
{
a0 = ((byte *) srcp1)[0];
b0 = ((byte *) srcp2)[0];
srcp1 += 1;
srcp2 += 1;
res = a0 - b0;
if (res != 0)
return res;
len -= 1;
}
/* SRCP2 is now aligned for memory operations on `op_t'.
SRCP1 alignment determines if we can do a simple,
aligned compare or need to shuffle bits. */
if (srcp1 % OPSIZ == 0)
res = memcmp_common_alignment (srcp1, srcp2, len / OPSIZ);
else
res = memcmp_not_common_alignment (srcp1, srcp2, len / OPSIZ);
if (res != 0)
return res;
/* Number of bytes remaining in the interval [0..OPSIZ-1]. */
srcp1 += len & -OPSIZ;
srcp2 += len & -OPSIZ;
len %= OPSIZ;
}
/* There are just a few bytes to compare. Use byte memory operations. */
while (len != 0)
{
a0 = ((byte *) srcp1)[0];
b0 = ((byte *) srcp2)[0];
srcp1 += 1;
srcp2 += 1;
res = a0 - b0;
if (res != 0)
return res;
len -= 1;
}
return 0;
}