为什么crypt/blowfish使用两种不同的盐生成相同的哈希?

Der*_*sed 30 php hash crypt blowfish salt

这个问题与PHP的实现有关crypt().对于这个问题,盐的前7个字符不计算,所以盐' $2a$07$a'的长度为1,因为它只有盐的1个字符和元数据的7个字符.

当使用长度超过22个字符的salt字符串时,生成的哈希值没有变化(即截断),当使用短于21个字符的字符串时,盐将自动填充($显然有' '字符); 这是相当简单的.但是,如果给出20个字符的盐和21个字符的盐,除了21个长度的盐的最终字符外两个是相同的,两个散列字符串都是相同的.一个长22个字符的盐,除了最后一个字符外,它与21个长度的盐相同,哈希值会再次不同.

代码示例:

$foo = 'bar';
$salt_xx = '$2a$07$';
$salt_19 = $salt_xx . 'b1b2ee48991281a439d';
$salt_20 = $salt_19 . 'a';
$salt_21 = $salt_20 . '2';
$salt_22 = $salt_21 . 'b';

var_dump(
    crypt($foo, $salt_19), 
    crypt($foo, $salt_20), 
    crypt($foo, $salt_21), 
    crypt($foo, $salt_22)
);
Run Code Online (Sandbox Code Playgroud)

会产生:

string(60) "$2a$07$b1b2ee48991281a439d$$.dEUdhUoQXVqUieLTCp0cFVolhFcbuNi"
string(60) "$2a$07$b1b2ee48991281a439da$.UxGYN739wLkV5PGoR1XA4EvNVPjwylG"
string(60) "$2a$07$b1b2ee48991281a439da2.UxGYN739wLkV5PGoR1XA4EvNVPjwylG"
string(60) "$2a$07$b1b2ee48991281a439da2O4AH0.y/AsOuzMpI.f4sBs8E2hQjPUQq"
Run Code Online (Sandbox Code Playgroud)

为什么是这样?

编辑:

一些用户注意到整个字符串存在差异,这是正确的.在salt_20,偏移(28,4)是da$.,而在salt_21,偏移(28,4)是da2.; 但是,重要的是要注意生成的字符串包括散列,盐,以及生成盐的指令(即$2a$07$); 实际上,差异发生的部分仍然是盐.实际哈希值不变UxGYN739wLkV5PGoR1XA4EvNVPjwylG.

因此,这实际上并不是产生的散列的差异,而是用于存储散列的盐的差异,这恰恰是手头的问题:两个盐生成相同的散列.

Rembmer:输出将采用以下格式:

"$2a$##$saltsaltsaltsaltsaltsaHASHhashHASHhashHASHhashHASHhash"
//                            ^ Hash Starts Here, offset 28,32
Run Code Online (Sandbox Code Playgroud)

其中##是log-base-2,用于确定算法运行的迭代次数

编辑2:

在评论中,要求我发布一些额外的信息,因为用户无法重现我的输出.执行以下代码:

var_dump(
    PHP_VERSION, 
    PHP_OS, 
    CRYPT_SALT_LENGTH, 
    CRYPT_STD_DES, 
    CRYPT_EXT_DES, 
    CRYPT_MD5, 
    CRYPT_BLOWFISH
);
Run Code Online (Sandbox Code Playgroud)

产生以下输出:

string(5) "5.3.0"
string(5) "WINNT"
int(60)
int(1)
int(1)
int(1)
int(1)
Run Code Online (Sandbox Code Playgroud)

希望这可以帮助.

Der*_*sed 35

经过一些实验,我得出结论,这是由于盐的处理方式.salt不被认为是文字文本,而是作为base64编码的字符串,这样22个字节的salt数据实际上代表一个16字节floor(22 * 24 / 32) == 16的salt ()盐."Gotcha!" 但是,与Unix crypt一样,它使用"非标准"base64字母表.确切地说,它使用这个字母表:

./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$
Run Code Online (Sandbox Code Playgroud)

第65个字符' $'是填充字符.

现在,该crypt()函数似乎能够获取任何长度小于或等于其最大值的salt,并通过丢弃任何不构成另一个完整字节的数据来静默处理base64中的任何不一致.如果你在盐中传递不属于其base64字母表的字符,那么crypt函数将完全失败,这只是证实了它的操作理论.

拿一个想象中的盐' 1234'.这完全是base64一致的,因为它代表24位数据,所以3个字节,并且不携带任何需要丢弃的数据.这是一个Len Mod 4零的盐.将任何字符附加到该盐,它变为5个字符的盐,Len Mod 4现在为1.但是,此附加字符仅表示6位数据,因此无法转换为另一个完整字节,因此将其丢弃.

因此,对于任何两种盐A和B,其中

   Len A Mod 4 == 0 
&& Len B Mod 4 == 1  // these two lines mean the same thing
&& Len B = Len A + 1 // but are semantically important separately
&& A == substr B, 0, Len A
Run Code Online (Sandbox Code Playgroud)

crypt()事实上,用于计算散列的实际盐将是相同的.作为证据,我将包含一些可用于显示此示例的PHP代码示例.盐以非随机的方式不断旋转(基于当前时间的漩涡散列的随机段到微秒),并且要散列的数据(此处称为$seed)仅仅是当前的Unix-Epoch时间.

$salt = substr(hash('whirlpool',microtime()),rand(0,105),22);
$seed = time();
for ($i = 0, $j = strlen($salt); $i <= $j; ++$i) {
    printf('%02d = %s%s%c',
        $i,
        crypt($seed,'$2a$07$' . substr($salt, 0, $i)),
        $i%4 == 0 || $i % 4 == 1 ? ' <-' : '',
        0x0A
    );
}
Run Code Online (Sandbox Code Playgroud)

这产生类似于以下的输出

00 = $2a$07$$$$$$$$$$$$$$$$$$$$$$.rBxL4x0LvuUp8rhGfnEKSOevBKB5V2. <-
01 = $2a$07$e$$$$$$$$$$$$$$$$$$$$.rBxL4x0LvuUp8rhGfnEKSOevBKB5V2. <-
02 = $2a$07$e8$$$$$$$$$$$$$$$$$$$.WEimjvvOvQ.lGh/V6HFkts7Rq5rpXZG
03 = $2a$07$e89$$$$$$$$$$$$$$$$$$.Ww5p352lsfQCWarRIWWGGbKa074K4/.
04 = $2a$07$e895$$$$$$$$$$$$$$$$$.ZGSPawtL.pOeNI74nhhnHowYrJBrLuW <-
05 = $2a$07$e8955$$$$$$$$$$$$$$$$.ZGSPawtL.pOeNI74nhhnHowYrJBrLuW <-
06 = $2a$07$e8955b$$$$$$$$$$$$$$$.2UumGVfyc4SgAZBs5P6IKlUYma7sxqa
07 = $2a$07$e8955be$$$$$$$$$$$$$$.gb6deOAckxHP/WIZOGPZ6/P3oUSQkPm
08 = $2a$07$e8955be6$$$$$$$$$$$$$.5gox0YOqQMfF6FBU9weAz5RmcIKZoki <-
09 = $2a$07$e8955be61$$$$$$$$$$$$.5gox0YOqQMfF6FBU9weAz5RmcIKZoki <-
10 = $2a$07$e8955be616$$$$$$$$$$$.hWHhdkS9Z3m7/PMKn1Ko7Qf2S7H4ttK
11 = $2a$07$e8955be6162$$$$$$$$$$.meHPOa25CYG2G8JrbC8dPQuWf9yw0Iy
12 = $2a$07$e8955be61624$$$$$$$$$.vcp/UGtAwLJWvtKTndM7w1/30NuYdYa <-
13 = $2a$07$e8955be616246$$$$$$$$.vcp/UGtAwLJWvtKTndM7w1/30NuYdYa <-
14 = $2a$07$e8955be6162468$$$$$$$.OTzcPMwrtXxx6YHKtaX0mypWvqJK5Ye
15 = $2a$07$e8955be6162468d$$$$$$.pDcOFp68WnHqU8tZJxuf2V0nqUqwc0W
16 = $2a$07$e8955be6162468de$$$$$.YDv5tkOeXkOECJmjl1R8zXVRMlU0rJi <-
17 = $2a$07$e8955be6162468deb$$$$.YDv5tkOeXkOECJmjl1R8zXVRMlU0rJi <-
18 = $2a$07$e8955be6162468deb0$$$.aNZIHogUlCn8H7W3naR50pzEsQgnakq
19 = $2a$07$e8955be6162468deb0d$$.ytfAwRL.czZr/K3hGPmbgJlheoZUyL2
20 = $2a$07$e8955be6162468deb0da$.0xhS8VgxJOn4skeI02VNI6jI6324EPe <-
21 = $2a$07$e8955be6162468deb0da3.0xhS8VgxJOn4skeI02VNI6jI6324EPe <-
22 = $2a$07$e8955be6162468deb0da3ucYVpET7X/5YddEeJxVqqUIxs3COrdym
Run Code Online (Sandbox Code Playgroud)

结论?双重.首先,它按预期工作,其次,知道自己的盐或不自己的盐.


soo*_*oot 7

很好的答案,清晰的解释.但在我看来,实现中存在一个错误,或者需要对意图进行一些进一步的解释{对帖子的评论解释了为什么没有错误}.在当前的PHP文件中指出:

CRYPT_BLOWFISH - 使用盐进行河豚散列如下:"$ 2a $",两位数的成本参数"$",以及字母"./0-9A-Za-z"中的22位64位数字.在salt中使用此范围之外的字符将导致crypt()返回零长度字符串.两位数的成本参数是底层基于Blowfish的散列算法的迭代计数的基数2对数,并且必须在04-31范围内,超出此范围的值将导致crypt()失败.

这与此处陈述和演示的内容一致.不幸的是,文档没有非常有用地描述返回值:

返回散列字符串或短于13个字符的字符串,并保证与失败时的salt不同.

但是如Dereleased的回复所示,如果输入salt字符串有效,则输出由输入盐填充到带有'$'字符的固定长度,并附加32字符计算的哈希值.不幸的是,结果中的盐被填充到只有21个base64数字,而不是22个!这个回复的最后三行显示了这一点,我们看到一个'$'代表20位数字,没有'$'代表21,当盐中有22个base64数字时,散列结果的第一个字符取代了输入盐的第22位数字.该函数仍然可用,因为它计算的完整值可供调用者使用substr(crypt($pw,$salt), 28, 32),并且调用者已经知道完整的salt值,因为它将该字符串作为参数传递.但是很难理解为什么设计返回值使得它只能给出128位的盐位值.事实上,很难理解它为什么包含输入盐; 但省略2位是非常不可思议的.

这里有一个小片段,显示第22个base64数字仅为计算中实际使用的盐贡献了两个位(产生的哈希值只有4个):

$alphabet = './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
$lim = strlen($alphabet);
$saltprefix = '$2a$04$123456789012345678901'; // 21 base64 digits


for ($i = 0; $i < $lim; ++$i ) {
  if ($i = 16 || $i == 32 || $i == 48) echo "\n";
  $salt = $saltprefix . substr($alphabet, $i, 1);
  $crypt = crypt($password, $salt);
  echo "salt ='$salt'\ncrypt='$crypt'\n";
}

salt ='$2a$04$123456789012345678901.'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901/'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901A'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901B'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901C'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901D'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901E'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901F'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901G'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901H'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901I'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901J'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901K'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901L'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901M'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'
salt ='$2a$04$123456789012345678901N'
crypt='$2a$04$123456789012345678901.YpaB4l25IJ3b3F3H8trjHXj5SC1UbUW'

salt ='$2a$04$123456789012345678901O'
crypt='$2a$04$123456789012345678901Ots44xXtSV0f6zMrHerQ2IANdsJ.2ioG'
salty='$2a$04$123456789012345678901P'
crypt='$2a$04$123456789012345678901Ots44xXtSV0f6zMrHerQ2IANdsJ.2ioG'
salty='$2a$04$123456789012345678901Q'
crypt='$2a$04$123456789012345678901Ots44xXtSV0f6zMrHerQ2IANdsJ.2ioG'
  ... 13 more pairs of output lines with same hash

salt ='$2a$04$123456789012345678901e'
crypt='$2a$04$123456789012345678901e.1cixwQ2qnBqwFeEcMfNfXApRK0ktqm'
  ... 15 more pairs of output lines with same hash

salt ='$2a$04$123456789012345678901u'
crypt='$2a$04$123456789012345678901u5yLyHIE2JetWU67zG7qvtusQ2KIZhAa'
  ... 15 more pairs of output lines with same hash
Run Code Online (Sandbox Code Playgroud)

相同散列值的分组还表明,实际使用的字母表的映射最有可能是在此处写入,而不是按照其他答复中显示的顺序.

也许接口是以这种方式设计的,以实现某种兼容性,也许是因为它已经以这种方式发布它无法更改.{该帖子的第一条评论解释了为什么界面是这样的}.但当然文档应该解释发生了什么.为了防止错误在某一天得到修复,或许最安全的方法是获取哈希值:

substr(crypt($pw,$salt), -32)
Run Code Online (Sandbox Code Playgroud)

作为最后一点,虽然说明为什么哈希值在指定的base64数字的数量mod 4 == 1在代码可能以这种方式表现的方面有意义时重复,但它并不能解释为什么以这种方式编写代码是个好主意.代码可以并且可以说应该包括来自base64数字的位,这些位在计算散列时构成部分字节,而不是仅丢弃它们.如果代码是以这种方式编写的,那么输出中丢失第22位盐的问题似乎也不会出现.{正如对帖子的评论所解释的那样,即使第22位被覆盖,覆盖它的哈希数字也只是四个可能值中的一个[.Oeu],这些是第22位数的唯一重要值.如果第22个数字不是这四个值中的一个,它将被生成相同散列的四个数字中的一个替换.}

根据评论,似乎很明显没有错误,只是令人难以置信的沉默文档:-)因为我不是一个密码学家,我不能用任何权威说这个,但在我看来,这是一个弱点21位盐显然可以产生所有可能的哈希值的算法,而22位盐则将哈希的第一个数字限制为仅四个值中的一个.

  • 啊,现在返回值的格式有意义,谢谢!如果你真的使用了22位盐,我就要说这个方程式太糟糕了,但我发现事实并非如此.虽然计算哈希的第一个数字会覆盖输入盐的第22位,但是当第22位是[.Oeu]之一时,该数字也是哈希的第一个数字!那些base64数字分别具有值0,16,32,48 - 高阶两位.由于二进制盐是128位而22位是132位,所以忽略其他四位. (2认同)