MD5长度扩展攻击

背景

以往网站开发人员常常把用户密码的md5值存储在数据库,但攻击者可以生成md5字典(彩虹表),进行爆破和反查。

为了加强安全,开发人员又通过加盐(salt)的方式来存储密码数据。

具体措施是自定义一个字符串,这个字符串谁都看不到。当用户进行注册的时候,把md5(salt+密码)的值进行存储,以后用户每次登陆都在后台把用户输入的密码加盐再md5加密与数据库进行比较。

由于攻击者不知道盐是什么,很难通过爆破的方式来获取密码。

攻击效果

已知

  1. MD5 ( secret || salt || data)
  2. data 的明文
  3. secret + salt 的长度

==> 可以求出 MD5 ( secret || salt || data + padding + anything )

//已知
//1. MD5 ( s )
//2. s 的长度

//==> 可以求出 MD5 ( s + padding + anything )

攻击原理

相关 MD5 原理简介

哈希算法需要做到** 任意长度输入,固定长度输出的 ** ,所以 MD5 采用了类似分组密码的方式,对明文进行压缩。

每个分组的长度是 512 位 ,即 64 字节。如果明文不是分组长度的整数倍,就需要填充(和分组密码类似)。

填充分为两歩。

  • 首先使他补充到N64+56字节。填充的规则(按照二进制)是先填一个1,之后再填0。按照16进制来算,就是先填一个8,然后填0,直到长度满足N64+56为止。

  • 填充的第二步。之前的长度已经是N64+56字节了,接下来我们还要填充8字节,用来描述原始二进制字符串的长度。这样最后的数据就是(N+1)64字节。
    例如,'abcd'这四个字母,他的长度是4个字节也就是32bit,用十六进制来描述其长度的话就是20,这样最后最后八个字节就是20000000。
    (为什么是20000000而不是00000020呢,因为md5中存储都是用的小端方式)。
    如果原始二进制字符串的长度超出了64位(8字节)所能表示的,就只取低长度的低64位。

每个 分组 的计算结果,将参与到下一个 分组 的计算,类似于 分组密码的 CBC 模式。

下图对 MD5 计算过程 和 填充方式 做了简单的示意:

漏洞简介

求 MD5( IV , secret + salt + data + padding + append)

现在已知一个中间结果,MD5( IV , secret + salt + data + padding),之后只要把这个中间结果,当做 MD5( IV , secret + salt + data + padding + append)的初始 IV ,继续运算,就可以得到 MD5( IV , secret + salt + data + padding + append)

换句话说,当我们知道一个 MD5(s) 和 s长度的时候,就相当于知道了 MD5(s + padding)的值,而 padding 可以通过 s 的长度推算出来。

进而我们可以把 MD5(s + padding) 作为新的 MD5(s + padding + append)的初始IV,计算出 MD5( s + padding + append)

也就是说,由于MD5(明文)== MD5(明文+padding),当已知 明文长度时,就可以推算出 padding。

就得到 明文2(即明文+padding)和 MD5(明文2)== MD5(明文)。

并在此基础上,进一步计算出 MD5(明文 + padding + anything)

一道 CTF 题目:[De1CTF 2019]SSRF Me

打开环境,首页就是源码

整理一下

#! /usr/bin/env python
#encoding=utf-8
from flask import Flask
from flask import request
import socket
import hashlib
import urllib
import sys
import os
import json
reload(sys)
sys.setdefaultencoding('latin1')

app = Flask(__name__)

secert_key = os.urandom(16)


class Task:
    def __init__(self, action, param, sign, ip):
        self.action = action
        self.param = param
        self.sign = sign
        self.sandbox = md5(ip)
        if(not os.path.exists(self.sandbox)):          #SandBox For Remote_Addr
            os.mkdir(self.sandbox)

    def Exec(self):
        result = {}
        result['code'] = 500
        if (self.checkSign()):
            if "scan" in self.action:
                tmpfile = open("./%s/result.txt" % self.sandbox, 'w')
                resp = scan(self.param)
                if (resp == "Connection Timeout"):
                    result['data'] = resp
                else:
                    print resp
                    tmpfile.write(resp)
                    tmpfile.close()
                result['code'] = 200
            if "read" in self.action:
                f = open("./%s/result.txt" % self.sandbox, 'r')
                result['code'] = 200
                result['data'] = f.read()
            if result['code'] == 500:
                result['data'] = "Action Error"
        else:
            result['code'] = 500
            result['msg'] = "Sign Error"
        return result

    def checkSign(self):
        if (getSign(self.action, self.param) == self.sign):
            return True
        else:
            return False


#generate Sign For Action Scan.
@app.route("/geneSign", methods=['GET', 'POST'])
def geneSign():
    param = urllib.unquote(request.args.get("param", ""))
    action = "scan"
    return getSign(action, param)


@app.route('/De1ta',methods=['GET','POST'])
def challenge():
    action = urllib.unquote(request.cookies.get("action"))
    param = urllib.unquote(request.args.get("param", ""))
    sign = urllib.unquote(request.cookies.get("sign"))
    ip = request.remote_addr
    if(waf(param)):
        return "No Hacker!!!!"
    task = Task(action, param, sign, ip)
    return json.dumps(task.Exec())
@app.route('/')
def index():
    return open("code.txt","r").read()


def scan(param):
    socket.setdefaulttimeout(1)
    try:
        return urllib.urlopen(param).read()[:50]
    except:
        return "Connection Timeout"



def getSign(action, param):
    return hashlib.md5(secert_key + param + action).hexdigest()


def md5(content):
    return hashlib.md5(content).hexdigest()


def waf(param):
    check=param.strip().lower()
    if check.startswith("gopher") or check.startswith("file"):
        return True
    else:
        return False


if __name__ == '__main__':
    app.debug = False
    app.run(host='0.0.0.0')

代码审计

要想拿到 flag ,就要运行 Task.Exec() 方法 , 并且

  1. self.checkSign() 为 true ==> getSign(self.action, self.param) == self.sign

==> md5(secert_key + self.param + self.action) == self.sign,其中 secret_key是 16 字节长的随机数。

  1. self.action 中包含 "scan" 和 "read"

发现我们可以在 /geneSign 拿到 md5(secert_key + self.param + "scan"),只要在 "scan"后面附加上 "read",并计算出对应的 MD5值,就能get flag。

先拿到一个 md5( secert_key + "scan" )

上 HashPump

拿到 md5( secert_key + "scan\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa0\x00\x00\x00\x00\x00\x00\x00read" )

6732ee41146311d9c9b52153ca982edc

Burp提交一下,没有数据返回

检查一下,原来是没有读 flag.txt , 看来 param 传空值还是不行,根据 Hint ,令 param = ./flag.txt 读取flag

到/geneSign 拿到 MD5( secert_key + "flag.txt" + "scan" )

hashpump 计算出 MD5( secert_key + "flag.txt" + "scan + padding + read" )

Burp,成功get flag

最后猜测一下 hashpump 的工作原理:

拿到 signature 作为初始 IV

拿到 data 和 keyLength 算出原文的长度,进而算出 padding 。padding 长度 = 64字节 -(原文长度 % 64字节)

Add = anything。

通过 MD5 算法 ,算出 MD5(原文 + padding + anything)

最后输出的是 data(原位 - secret) + padding + anything

可以看到 data 和 keyLength 对新的 MD5 值没有影响。(在一个分组长度之内)

posted @ 2022-04-24 21:46  191206  阅读(533)  评论(0编辑  收藏  举报