跳到主要内容

谈谈认证

· 阅读需 17 分钟
林林
在西安读大二的小菜鸡

HTTP是一个无状态的协议,但Web服务又需要认出访客(比如登录等功能),这两者就产生了一个矛盾。为了解决这个矛盾,前人想出了一些认证的办法,来证明“我是我,你是你”。

HTTP Basic Auth

HTTP状态码中有一个401 unauthorized状态码,服务端用来告诉客户端“未验证”,并在WWW-Authenticate头告诉客户端应该进行何种验证。Basic Auth算是验证方式最简单的一种。

我们用Flask构建一个要求basic验证的服务。

from flask import Flask, render_template
from flask_basicauth import BasicAuth

app = Flask(__name__)

app.config['BASIC_AUTH_USERNAME'] = 'example'
app.config['BASIC_AUTH_PASSWORD'] = 'foo'

basic_auth = BasicAuth(app)

@app.route('/secret')
@basic_auth.required
def secret_view():
return render_template('secret.html')

if __name__ == "__main__":
app.run()

当我们访问/secret这个路由时,浏览器会跳出输入框让我们输入用户名和密码(严格来说是口令),当我们输入完examplefoo后,secret.html就渲染出来了。

basic-auth-1

可以看到刚才的鉴权页面服务器给浏览器发送了一个401状态码,和WWW-Authenticate: Basic realm=""头,这跟我们刚才的描述一样。它告诉浏览器要用Basic Auth进行身份验证。这里的realm有什么用呢?它描述受保护区域的字符串。realm允许服务器对它受保护的区域进行区分(如果允许支持这种划分方案),并通知用户需要哪个特定的用户名/密码。如果未指定 realm,客户端通常会显示格式化的主机名。1Emm,感觉不太会用到。但这个字段又是必须的(参见 RFC7235 第2.2节,但 MDN 又标明这个字段是可选的)。

basic-auth-challenge-header

我们可以看到浏览器在请求这个页面的时候在Authorization里面加了一个Basic ZXhhbXBsZTpmb28=Basic代表使用的鉴权方式是Basic Auth,后面那个字符串是一个base64编码。解码之后得到example:foo,也就是username:passwd

basic-auth-2

浏览器有时会重用之前的验证信息,这样就不用每次都验证了。比如在http://example.com/docs/index.html经过的身份验证,访问http://example.com/docs/http://example.com/docs/test.dochttp://example.com/docs/?page=1时浏览器会自动发送先前的Authorization Header,而http://example.com/other/https://example.com/docs/不会发送,需要重新验证。(参见 RFC 7617 2.2. Reusing Credentials)

Basic Auth最大的安全漏洞在于对密码进行明文传输(尽管经过了base64编码,但跟明文无异),所以Basic Auth通常需要配合HTTPS使用。这种验证方式还不防重放(Replay Attack)。现在很少见有服务采用这种验证方式。

Digest Auth

Digest Auth只用摘要值来验证身份,不用明文传输密码,还可以防重放。我们实现一个最简单的应用。

from flask import Flask, request, redirect, render_template
from flask_digest_auth import DigestAuth, make_password_hash

app = Flask(__name__)
app.config['SECRET_KEY'] = 'foo-secret-key'
auth: DigestAuth = DigestAuth("secret-path")

users = {}
users['admin'] = make_password_hash("secret-path", 'admin', '123456')

@auth.register_get_password
def get_password_hash(username):
return users.get(username)

@auth.register_get_user
def get_user(username):
if username in users:
return {"username": username}
return None

auth.init_app(app)

@app.get("/admin")
@auth.login_required
def admin():
return render_template("secret.html")

if __name__ == "__main__":
app.run()

初次访问/admin路由时服务器返回WWW-Authenticate: Digest realm="secret-path", nonce="MjQ5ODkxODU0Nw.aqgMag.Bbxymw21v8cFvF1T9REueRbsi_w", opaque="Mjk0NDc5Njc1Nw.aqgMag.PL6QtVO2qRimpCSEwdMxEuRSdng", qop="auth,auth-int"

用户输入对应的密码后,浏览器在Authorization Header放Authorization: Digest username="admin", realm="secret-path", nonce="MzU2MjU1MzAw.aqgL2A.ceyzaVy7zEo1z5Xde9BtRoEz1iI", uri="/admin", response="aee55ad787a9c950b831ddfe9df88cdf", opaque="MTQwNzgzODUxOA.aqgL2A.wP4E08IN0BzYKQqrgSoxG26Yihg", qop=auth, nc=0000000f, cnonce="cda1b7777dcd4e90",用户就能访问到受保护的路由了。(这两次请求的nonce不一样,每次请求都会生成一个随机的nonce,后面计算的时候需要用浏览器这一段的信息)

参数比刚才的Basic Auth多多了,我们看一下发生了什么。

WWW-Authenticate头部中各字段的作用:

名称作用
nonce服务端生成的随机唯一字符串
opaque服务端指定的字符串,客户端需要原封不动返回
stale标志,指示由于nonce值过时而拒绝了来自客户端的上一个请求
algorithm哈希算法,SHA256、MD5等
qop必填,值auth表示身份验证,值auth-int表示具有完整性保护的身份验证
charset字符集
userhash标志,指示服务端是否支持用户名哈希

Authorization头部各字段的作用:

名称作用
response按照规则计算出的十六进制数字串(hex)
username用户名
realm
uri请求的uri
qop指示客户端采用的qop
cnonce客户端提供的字符串
nc必填,nonce count,防重放
userhash指示用户名是否被哈希

response 的计算规则

response=KD(H(A1),  nonce:nc:cnonce:qop:H(A2))\text{response} = \text{KD}\Big( H(A_1),\; \text{nonce} : \text{nc} : \text{cnonce} : \text{qop} : H(A_2) \Big)

H():哈希函数,如 MD5、SHA-256

KD():密钥派生函数,本质是 H(secret, data)

对于普通算法来说,

A1=username:realm:passwdA_1 = \text{username} : \text{realm} : \text{passwd}

对于会话算法(-sess),

A1=H(username:realm:passwd):nonce:cnonceA_1 = H(\text{username} : \text{realm} : \text{passwd}) : \text{nonce} : \text{cnonce}

qop = auth 或未指定:

A2=Method:request-uriA_2 = \text{Method} : \text{request-uri}

qop = auth-int

A2=Method:request-uri:H(entity-body)A_2 = \text{Method} : \text{request-uri} : H(\text{entity-body})

用户名哈希

username=H(username:realm)\text{username} = H(\text{username} : \text{realm})

我们用这种方法计算一下刚才试验的结果。

名称
realmsecret-path
nonceMzU2MjU1MzAw.aqgL2A.ceyzaVy7zEo1z5Xde9BtRoEz1iI
cnoncecda1b7777dcd4e90
opaqueMjk0NDc5Njc1Nw.aqgMag.PL6QtVO2qRimpCSEwdMxEuRSdng
usernameadmin
password123456
uri/admin
qopauth
algorithm没有指定,默认MD5
methodGET
nc0000000f
A1=admin:secretpath:123456A2=GET:/adminA_1 = admin : secret-path : 123456 \\ A_2 = GET : /admin \\

A1哈希得到05a6b7c3154d30026f5009344ec192ab,A2哈希得到0b039339067eeaeff93a7d25514492d1

response = H(05a6b7c3154d30026f5009344ec192ab:MzU2MjU1MzAw.aqgL2A.ceyzaVy7zEo1z5Xde9BtRoEz1iI:0000000f:cda1b7777dcd4e90:auth:0b039339067eeaeff93a7d25514492d1),得到aee55ad787a9c950b831ddfe9df88cdf,跟刚才试验的结果相同。

对于服务器来说应该怎么验证response呢?客户端明文给服务端传了用户名,服务端里存储着用户名对应的密码,只要在服务端这里再进行一次哈希,比对一下两次哈希是否相同即可。

在Postman中对请求进行重放,发现服务器不认。注意直接在浏览器重放没用,因为服务器在浏览器验证成功后存了一个session cookie。

Cookie是以键值对形式存储在浏览器的一些短文本。但cookie可以被轻易查看和修改,如果将一些重要的东西以明文形式存在cookie中,不亚于直接将钥匙给小偷。CTF Web也只有教新手查看cookie的题目会这么出。

但如果服务器将一些信息存在服务器本地(session),然后提供了一个标识字符串给浏览器(cookie),服务器可以通过访客的cookie识别到对应的信息,信息对访客来说一般是不可读的(二般情况我们等会讲),避免了查看和篡改的风险。

session-cookie

下面看用flask构建一个简单的服务。

from flask import Flask, session, redirect, url_for, request

app = Flask(__name__)
app.secret_key = "change-me"

@app.route("/")
def index():
if "username" in session:
return f"""
<h1>欢迎回来,{session["username"]}!</h1>
"""
return "<h1>请登录</h1>"

@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "")
if username:
session["username"] = username
return redirect(url_for("index"))

return """
<form method="post">
<input type="text" name="username">
<input type="submit" value="登录">
</form>
"""

登录之后会发现多了一个名为session的cookie,值为eyJ1c2VybmFtZSI6ImFkbWluIiwidmlzaXRzIjowfQ.aqlKYw.uJksUqvQTxa_pmVIkBDyTe9AqQ8。此前有接触过类似东西的小伙伴可能会觉得这是jwt,实则并非。我们可以在Flask Session Cookie Decoder中读出session包含的内容。比如我们这个session的内容是:

{
"username": "admin",
"visits": 0
}

Flask session由三个部分组成,分别是数据、时间戳和签名组成,数据实际上是可读的,但不好篡改,因为有secret key。2服务器用相同的方式生成一个session比对后就能发现是否被篡改。这点跟后面的jwt有些相似,这样服务器就不用再维护一个数据库来存储一堆随机的cookie与它们的session的对应关系了。

flask-session-body

但如果这个secret key够弱(比如我们文中的这个),就可以用弱口令词典去爆破。

PS C:\> flask-unsign --unsign --cookie "eyJ1c2VybmFtZSI6ImFkbWluIiwidmlzaXRzIjowfQ.aqlKYw.uJksUqvQTxa_pmVIkBDyTe9AqQ8"
[*] Session decodes to: {'username': 'admin', 'visits': 0}
[*] No wordlist selected, falling back to default wordlist..
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 42240 attempts
'change-me'

得到secret key之后就可以随意伪造session了。除了用flask-unsign之外,还可以自己搭一个flask去签发假session,也可以用脚本签发。

from flask.sessions import SecureCookieSessionInterface
from itsdangerous import URLSafeTimedSerializer

class S(SecureCookieSessionInterface):
def get_signing_serializer(self, secret_key):
return URLSafeTimedSerializer(
secret_key,
salt=self.salt,
serializer=self.serializer,
signer_kwargs=dict(
key_derivation=self.key_derivation,
digest_method=self.digest_method,
),
)

serializer = S().get_signing_serializer('change-me')
fake_cookie = serializer.dumps({'username': 'admin', 'visits': 999})
print(fake_cookie)

Flask也有其他session相关的库,比如flask-session,它提供了一种server-side session,也就是session的内容存储在服务器上。对应的有client-side session,也就是我们刚才见识的flask默认的session。

我们可以用这个代码来看看server-side session。

from flask import Flask, session
from flask_session import Session

app = Flask(__name__)
# Check Configuration section for more details
SESSION_TYPE = 'filesystem'
app.config.from_object(__name__)
Session(app)

@app.route('/set/')
def set():
session['key'] = 'admin'
return 'ok'

@app.route('/get/')
def get():
return session.get('key', 'not set')

if __name__ == "__main__":
app.run()

浏览器先访问/set/,再访问/get,名为session的cookie值为FGN9IPiXxrCrjRLy5uPCMru-Y8zOxnHMfuipu9hSFtU。这个字符串就不太能读了。我们发现在我们的代码目录下多出了一个名为flask_session的文件夹,session的信息就存储在里面。感觉里面存储了序列化的东西,可以用下面这个脚本大致读出来{'_permanent': True, 'key': 'admin'},但在此之前还存了一些字符,究竟是什么意思,具体机制有待探究。

import pickle

with open('./f98473cdd5c467387ed7280ce6be805d', 'rb') as f:
data = f.read()

obj = pickle.loads(data[15:])
print(obj)

Session Cookie这种验证方式不防重放,如果被窃取到了攻击者就可以原封不动地用session伪装用户进行操作。要防重放的话就要用其他手段(比如多加一个nonce等等)。对于client-side session来说保护内容不被篡改其实就是保证有一个强的secret key,而server-side session则没有篡改的问题。

当然server-side session虽然不存在secret key导致的问题,但面临着会话劫持攻击的风险。尽管目前成熟应用的sessionid都很复杂,难以预测和暴力破解,但需要采取措施防范攻击者窃取sessionid。这一点在本文上下都有提到。

Bearer Token

这个验证本身非常简单,很多服务都在用,就是在Authorization头里面加服务器给的token就可以了。

Authorization: Bearer <token>

对于服务器来说一般需要维护一个token的数据表,跟server-side session差不多。本身不防重放,需要使用HTTPS等手段保护token。

这个鉴权机制本身非常简单,但如何保护token,防护攻击需要一些策略。

首先,最基础的就是HTTPS,没有它一方面数据会像前面提及的那样裸奔,用它来抵御中间人攻击等。

其次,token本身不能直接放在cookie里,因为如果用户被诱骗访问https://example.com/buy之类的接口,浏览器会自动发送cookie内容,完成鉴权,在不经意间完成敏感操作。这就是跨站请求伪造(CSRF)攻击。

那么,token应该存哪呢?token应该作为返回值,由前端存到localStorage中。这样浏览器在访问链接时并不会主动发送token,而是由前端将token放到接口请求的Authorization头部。

但即便这样,恶意的js脚本还是能访问到localStorage,面临xss攻击的问题,可以交由Content-Security-Policy和前后端的过滤机制来防范。

JWT

前面Bearer Token一般是不可读的,前端收到服务器发过来的token不知道这个字符串代表着什么意思,如果要获取用户信息的话还需要向类似auth/me之类的接口获取数据。有没有既能鉴权,又能包含用户信息的呢?

你可能想到前面的client-side session。除了这个,还可以用JWT(JSON Web Token)。

在介绍JWT之前,讲一个场景。拿server-side session举例子,服务器先将用户的id等信息存到session中,并将session-id存到cookie,后续用户发送携带session-id的请求,服务器再到session中查找信息。这个场景对单点服务没有问题,但如果后端是一个服务器集群呢?上游的cdn会将请求发送到其中一个节点,此时很有可能会出现session不在该节点的问题,用户的鉴权态就无效了。

要解决这个问题,session就不能存储在节点自己的内存中了,可以存储到统一的数据库中。但每次验证都要从数据库中取信息,多了来回的耗时,压力也给到数据库这里。而且数据库一旦挂了,所有需要鉴权的服务统统都挂了。

另一种方案是服务器索性不保存session了,所有数据都保存在客户端,每次请求都发回服务器。这就是JWT。

JWT由三个部分构成,头部(Header)、载荷(Payload)和签名(Signature),每个部分用.分隔。

下面用PyJWT库生成json web token。

import jwt

payload = {"username": "admin"}
secret = "secret"
encoded_jwt = jwt.encode(payload, secret, algorithm="HS256")
print(encoded_jwt)

运行得到eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.qQSekbR5BFKQPc3_7gUiDY6Q9y7RojKzvBTLJ9jGtec,可以在JWT 解析工具查看这个字符串每个部分的含义。

header部分描述jwt的元数据,alg为jwt签名用的算法(默认为HMAC SHA256),typ为JWT

{
"alg": "HS256",
"typ": "JWT"
}

body也是一个json对象,要使用Base64URL算法转成字符串,官方提供了一些字段供选用:

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

可以用相同的库进行jwt的解析,当密钥不相同时会抛出jwt.exceptions.InvalidSignatureError异常。

secret = "fakesecret"
decoded_jwt = jwt.decode(encoded_jwt, secret, algorithms="HS256")
print(decoded_jwt)

签发jwt同样需要一个强密钥,不然会被暴力破解。这里尝试用字符集进行暴力破解,用时比较长,用弱密码字典会更快,比如说用rockyou字典77ms就出来了。

./jwt_tool.exe brute -token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.qQSekbR5BFKQPc3_7gUiDY6Q9y7RojKzvBTLJ9jGtec" -minLen 4 -maxLen 10 -charset "qwertyuiopasdfghjklzxcvbnm" -threads 0
尝试次数: 143933669 当前密钥: sescms
成功破解密钥!密钥为: secret
总尝试次数: 144096418, 耗时: 4m7.4560252s
./jwt_tool.exe crack -token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.qQSekbR5BFKQPc3_7gUiDY6Q9y7RojKzvBTLJ9jGtec" -dict ".\rockyou-65.txt"
成功破解密钥!密钥为: secret
Measure-Command { ./jwt_tool.exe crack -token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.qQSekbR5BFKQPc3_7gUiDY6Q9y7RojKzvBTLJ9jGtec" -dict ".\rockyou-65.txt" }

Milliseconds : 77
Ticks : 770263
TotalMilliseconds : 77.0263

除此之外,jwt payload并没有加密,所以不能把一些敏感信息放到jwt中。

jwt在应用的过程中有很多漏洞,比如alg=none和加密算法混淆等,对应的也有很多的利用方法。可以阅读Burpsuite靶场-JWT漏洞原理总结及复现这篇文章了解。后续再对jwt有关的漏洞进行整理,这里暂不赘述。

TOTP

认证因素分为这样几类:

  • 知识因素(Knowledge factors):用户所知内容,例:密码、PIN码、共享秘钥、与自身相关的信息
  • 持有因素(Possession factors):用户拥有的东西,比如身份证、护照、安全令牌或智能手机
  • 属性因素(Inherence factors):用户具备的特征,通常是生物识别技术,物理特征映射的个人属性,如指纹、面部、声音,还包括行为识别,例如说话方式、走路方式、打字方式识别。

TOTP作为双因素验证(2Fa)中比较常见的一种方法,可以通过验证用户的持有因素来提高安全性。

因为用户的持有因素可能丢失、失窃,所以TOTP一般不作为服务唯一的验证因素。

在开启TOTP时,服务器会先生成一个密钥。

import pyotp

secret_key = pyotp.random_base32()
print(secret_key)

随后会向前端返回一个链接,label是用户的名字,secret是上面经过Base32编码后的密钥,issuer代表应用名。

totp = pyotp.TOTP(secret_key)
uri = totp.provisioning_uri(name="admin", issuer_name="totp test")
print(uri) # otpauth://totp/{label}?secret={secret}&issuer={issuer}

前端将链接转换为二维码供用户扫描,用户将这个链接的信息添加到设备的TOTP生成器上。用户再次登录时,后端可以计算用户的TOTP,并将两者进行比对。

user_code = input()
if totp.verify(user_code):
print("Valid")
else:
print("Invalid")

OAuth / OpenID Connect(OIDC)

to be written

WebAuthn

to be written

Reference

Basic Auth:

Digest Auth:

Bearer Token:

Session Cookie:

JWT:

TOTP:

OAuth:

Footnotes

  1. 参考WWW-Authenticate - HTTP | MDN

  2. 国外有位大佬对flask session进行分析,并开发了flask-unsign这个在文中使用到的爆破工具,描述session内容的图片就来自他的博客,但博客现在没有办法访问了,可以去互联网档案馆阅读。