Web认证
参考链接
https://baobao555.tech/archives/40
https://blog.csdn.net/YZL40514131/article/details/119669229
为什么需要认证?
HTTP协议是一种无状态的协议,也就是说,每组HTTP-request和reply其实都可以理解为独立的。但这使得很多功能无法实现——实际上,用户在与网站沟通时的所有先前操作与信息都无法保存,无法实现“登录”功能。
后续内容都以 通过认证保持用户登录状态 为例进行叙述。实际认证的应用场景远不止于此。
认证方式
认证的核心思想就是使客户端在HTTP报头中添加额外的信息,让服务器能够识别客户端的用户登录状态和其他已做出的操作。
具体的,其分为服务端需要存储额外数据的session
和服务端不需要存储额外数据的token
(暂时这么区分吧)
HTTP报头的cookie字段是最常见的存储这些额外信息的位置;但实际上,这些信息可以存储在其他位置,有些还可以取得更好的效果。
消息认证码的要求
以用户登录为例,最基础的消息认证码也至少要:
(1)通过用户名+密码生成;
(2)无法从消息认证码还原出用于生成它的信息。
但是,这样的消息认证码显然是不完美的;一个显而易见的问题是,它不是针对会话的,而是对于每个用户唯一的。这就导致它无法设置有效期,且一旦泄露会产生永久性的危害(除非用户修改密码)。
所以,还需添加条件:
(3)生成参数中需要引入多个随机量(时间戳,毫秒数,用其他方法生成的伪随机数等)
在“用户登录”的语境下,上述(1)(2)-->(3)
的思考逻辑是很合理的,但实际上,由于传统的消息认证码Session是基于 会话 的思想产生的,更合理的实际上是(3)(2)-->(1)
的逻辑【(2)
是密码学要求,得一直放在首位】:
会话能够把用户与同一用户发出的不同请求之间关联起来,它不要求用户登录与否,也不要求用户在会话建立时额外发送某些认证信息。而获得一个会话对应的消息认证码就可以伪造成该会话的用户端(虽然危害比之前那个小,但仍不容小觑)。后端生成消息认证码的算法往往是公开的,因此,这种会话的安全性就完全依赖于生成参数的随机性,即(3)
。
至于用户名密码啥的,那都是会话里面的内容。
传统的Session认证
工作流程
(1)用户端输入用户名和密码进行登录操作
(2)服务端使用用户名、密码和一些具有随机性的系统参数生成Session_id,并将该ID存储起来
(3)服务端在HTTP-reply报文中采用Set-cookie字段发送该ID
(4)用户端浏览器在收到具有Set-cookie字段的报头后,自动把其中内容加入HTTP-request的Cookie字段。此后,在连接关闭(关闭浏览器/Session_id过期)之前,浏览器发送的所有HTTP-request报文的Cookie字段中都会包含该Session_id。
(5)服务端收到Cookie中包含Session_id的报文,去它存储的位置查询这个ID。如果该ID存在,它往往还有很多关联信息(该次会话中用户之前的操作)。服务器基于request报文和这些关联信息返回对应的Reply报文。
实例分析——以PHP为例
源码审计
从PHP官网上(https://www.php.net/releases/)下载PHP源码(apt安装的好像没有源码,至少我没找到)
以PHP7.2.19为例,Session_id生成相关的内容在./ext/session/session.c
的310行左右。
PHPAPI zend_string *php_session_create_id(PS_CREATE_SID_ARGS) /* {{{ */
{
unsigned char rbuf[PS_MAX_SID_LENGTH + PS_EXTRA_RAND_BYTES];
zend_string *outid;
/* Read additional PS_EXTRA_RAND_BYTES just in case CSPRNG is not safe enough */
if (php_random_bytes_throw(rbuf, PS(sid_length) + PS_EXTRA_RAND_BYTES) == FAILURE) {
return NULL;
}
outid = zend_string_alloc(PS(sid_length), 0);
ZSTR_LEN(outid) = bin_to_readable(rbuf, PS(sid_length), ZSTR_VAL(outid), (char)PS(sid_bits_per_character));
return outid;
}
全都是自己定义的东西和其他函数,不能直接看出算法。不过看那行注释,知道用了伪随机数产生器CPRNG(Cryptographically Secure Pseudo-Random Number Generator),而且还怕它不安全又做了个校验,应该挺安全的(逃)
对这块内容的详细讲解可以看http://bbs.chinaunix.net/forum.php?mod=viewthread&tid=3583605,用的是PHP5.3.6。
操作实验
示例代码
<?php
session_start();
echo session_save_path();
echo "<br>Session_id=".session_id();
$_SESSION['user']=$_GET['user'];
echo "<br>".$_SESSION['user'];
?>
session_start()
即会启动Session功能。PHP里自带的Session显示是面向会话的,故不需要(也不能)用账号密码生成。
session_save_path()
字面意思;它是PHP自带的环境变量。按道理说,session的存储位置可以在php.ini里通过session_save_path查询,但在linux里,它不是直接标出来的;所以在脚本里输出来比较好。
$_SESSION['user']=$_GET['user'];
是测试语句;它用于说明:不同用户进行访问时(在不同的会话中),$_SESSION系列的变量不是同一个变量,不在同一个地方存储。
效果如下面这些图所示。
这些有利于我们加深理解:
(1)Session只在生成时进行计算
(2)Session由服务器存储;一直参与HTTP传输的只有Session_id,而不是Session文件的全部
(3)之后,服务器所要做的事情有:
<1>将客户端Session与本地比对
<2>从本地对应Session文件中查询信息
<3>向本地对应Session文件中添加/修改信息
在PHP中,我们使用Session时不需要用参数来创建会话;只要等用户登录的时候,把登录信息存到Session里就可以了。
Token认证
传统Session的弊端
(1)服务器开销较大。每个用户都要创建一个独立的Session文件存储不少信息,在访问用户很多的情况下,给服务器的压力较大。
(2)在分布式系统中实现困难。服务器去“本地”查询Session这件事情在分布式系统中的实现开销和复杂度是一件不可忽略的事情。
(3)在前后端分离的系统中实现困难。之前演示中使用的是单一的PHP语言,但现在其实很多系统都是前端框架+后端处理,会非常混乱
Token工作流程
(1)用户端输入用户名和密码进行登录操作
(2)服务端使用用户名、密码和一些具有随机性的系统参数生成Token;服务端并不存储该token
(3)服务端在HTTP-reply报文中采用Set-cookie字段发送该ID
(4)用户端浏览器在收到具有Set-cookie字段的报头后,自动把其中内容加入HTTP-request的Cookie字段。此后,在连接关闭(关闭浏览器/Session_id过期)之前,浏览器发送的所有HTTP-request报文的Cookie字段中都会包含该Token。
(5)服务端收到Cookie中包含Token的报文,进行数学计算,验证该Token的有效性。如果该Token有效,就承认Token中携带的有效载荷,并基于request报文和有效载荷返回对应的Reply报文。
实例分析1——Flask_Session
把Flask_Session放在Token里讲的原因是,作为一个轻量级框架,Flask把它的“Session”放在服务端而不是客户端,它在数学上也具有与接下来要讲的Token相似的性质。
session默认存放在浏览器的cookie中源码wsgi->app.__call__->wsgi_app->push->self.app.session_interface->session_interface = SecureCookieSessionInterface()->open_session和save_session
它仍然叫“Session”的原因是,不同于接下来要讲的Token,Flask_Session实现的功能与PHP_Session(传统Session)更为相似。
源码审计
flask/session::SecureCookieSessionInterface
看注释;HMAC-SHA1算法,密码学课上学过,挺安全的。
就看注释就差不多了,代码也看不懂。
class SecureCookieSessionInterface(SessionInterface):
"""The default session interface that stores sessions in signed cookies
through the :mod:`itsdangerous` module.
"""
#: the salt that should be applied on top of the secret key for the
#: signing of cookie based sessions.
salt = "cookie-session"
#: the hash function to use for the signature. The default is sha1
digest_method = staticmethod(hashlib.sha1)
#: the name of the itsdangerous supported key derivation. The default
#: is hmac.
key_derivation = "hmac"
#: A python serializer for the payload. The default is a compact
#: JSON derived serializer with support for some extra Python types
#: such as datetime objects or tuples.
serializer = session_json_serializer
session_class = SecureCookieSession
def get_signing_serializer(
self, app: "Flask"
) -> t.Optional[URLSafeTimedSerializer]:
if not app.secret_key:
return None
signer_kwargs = dict(
key_derivation=self.key_derivation, digest_method=self.digest_method
)
return URLSafeTimedSerializer(
app.secret_key,
salt=self.salt,
serializer=self.serializer,
signer_kwargs=signer_kwargs,
)
def open_session(
self, app: "Flask", request: "Request"
) -> t.Optional[SecureCookieSession]:
s = self.get_signing_serializer(app)
if s is None:
return None
val = request.cookies.get(self.get_cookie_name(app))
if not val:
return self.session_class()
max_age = int(app.permanent_session_lifetime.total_seconds())
try:
data = s.loads(val, max_age=max_age)
return self.session_class(data)
except BadSignature:
return self.session_class()
def save_session(
self, app: "Flask", session: SessionMixin, response: "Response"
) -> None:
name = self.get_cookie_name(app)
domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app)
secure = self.get_cookie_secure(app)
samesite = self.get_cookie_samesite(app)
# If the session is modified to be empty, remove the cookie.
# If the session is empty, return without setting the cookie.
if not session:
if session.modified:
response.delete_cookie(
name, domain=domain, path=path, secure=secure, samesite=samesite
)
return
# Add a "Vary: Cookie" header if the session was accessed at all.
if session.accessed:
response.vary.add("Cookie")
if not self.should_set_cookie(app, session):
return
httponly = self.get_cookie_httponly(app)
expires = self.get_expiration_time(app, session)
val = self.get_signing_serializer(app).dumps(dict(session)) # type: ignore
response.set_cookie(
name,
val, # type: ignore
expires=expires,
httponly=httponly,
domain=domain,
path=path,
secure=secure,
samesite=samesite,
)
一些强调
Flask_Session的功能和之前PHP_Session非常相似,相同部分就不叙述了(也不实操了)
不同之处在于:
(1)由于信息都存储在客户端,故通信时不能只发一个ID;Flask_Session发送的内容中存在有效载荷,负责告诉服务器 用户的相关信息和先前的操作,而服务器只需验证它的真实性。
(2)对Flask_Session的验证不是文件查询,而是数学计算;故每次接收Flask_Session时服务器都要进行计算
(3)Flask_Session的生成(HMAC)需要服务器预设好一个密钥,这个密钥也同时被用于Flask_Session的真实性校验。
重点中的重点是,它需要密钥。
告一段落
CTF中,存在Flask_Session伪造的题目,这个东西相对之后讲的JWT伪造有一点复杂,就不在这里说了。在写相关题目的题解的时候,再详细讲讲Flask_Session伪造,顺便深入研究下Flask_Session。
实例分析2——JWT Token
本来就是为了JWT Token才写这篇随笔的,但写到这里,感觉其实没有特别多需要讲的了。
JWT Token是一种跨语言的,原则上任何Web形式都支持的,应用广泛的Token。它不需要在服务端保存会话信息,数据量小。(在我看来)它是真正应用于登录(而不是会话)的Token。
它分为三部分:
Header头部
仅使用Base64编码,不进行加密。
解码之后是一个JSON对象。
{
"alg": "HS256",
"typ": "JWT"
}
alg属性表示签名使用的算法,默认为HMAC-SHA256;MAC是消息认证符,HMAC表示用Hash函数生成的MAC。
常见的签名算法有:
HMAC【哈希消息验证码(对称)】:HS256/HS384/HS512
RSASSA【RSA签名算法(非对称)】(RS256/RS384/RS512)
ECDSA【椭圆曲线数据签名算法(非对称)】(ES256/ES384/ES512)
jwt.io里都提供了这些算法下的运算。
typ属性表示令牌的类型,JWT令牌统一写为JWT。
Payload有效载荷
默认情况下仅使用Base64编码,不进行加密;因此不要携带隐私信息字段!
解码之后也是一个JSON对象。
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
没必要管默认字段;随便搞自定义字段就得了,符合JSON语法就行。
Signature消息认证码
使用公式生成的签名。
Method(
b64encode(header)+'.'+b64encode(payload),
SecretKey
)
CTF题中的JWT伪造一般只能爆破JWT密钥。
在Ubuntu下使用jwt-cracker-master
就行了,安装使用方法跟着它的Readme来;注意需要预先装好libssl-dev和docker。
注意标注了签名算法,但密钥为空的情况,此时jwt-cracker爆不出来。建议先在题目里试一试这种情况下篡改payload,看能不能过。