Lattice-Vault:棋盘格与数字巨龙

01 / 谜面

两题共用同一套谜面结构:12×12 的圆点棋盘格,横坐标 2048/1024/…/2/1,纵坐标 1~12,旁边一枚 BIP39 水印。黑点为 1、空心为 0,每行读出 12 bit —— 但那不是数值,是 1-based 的词编号。 第一题只用到前 4 列对应的 32 bit;第二题附了一份 Guidelines,把整条链路写死在图上。

02 / 微博

发布 原微博 ↗
zfb的8位数字口令红包,20份。
发布 原微博 ↗
睡前迭代了新的题目,已经放进zfb口令红包。 这道题的难度或许要更加难一点,需要的知识和计算步骤也会多一些,但是信息冗余比上一份少很多。所以这次是200元/10份,有效期24小时。 PS:上一个红包( 微博正文 )目前已有13/20破译。 祝大家好运,晚安安大家w
总结 原微博 ↗
昨天发了两个口令红包,口令均隐藏在谜题里面。 出第一题的时候,没想仔细,有冗余信息,所以比较简单。( http://t.cn/AX5ICljv ) 第二题的时候多考虑了几步,难度高了一点,但一定程度上也更有趣一些。( http://t.cn/AX5M3RWs ) 第一个红包是88.88元/20份,4小时内被抢光 第二个红包是200元/10份,2小时内被抢光 红包是已经被抢光了,但如果还有朋友只是对谜题感兴趣,可以通过下面的链接验证自己的答案。(当然,也可以想办法“破解”这个无后端的验证工具来找到答案,但可能是更难的路径) prove.closeai.moe 晚一点会写下“谜题”正确的探索思路。
复盘 原微博 ↗
昨晚创建了一个叫做 Lattice-Vault 的目录,开始琢磨出谜题,彩头就是谜题能解出红包口令。一共出了两道题,但其实核心思路都差不多,但第一道题(图1)考虑不太仔细,导致整个谜面的信息无法完全被利用到。第二道题目(图2)就更加“完美”了。 以下信息就是“剧透”了,如果您还打算自己或利用AI来解谜,就先回避一下w ﹥ 谜面上最显眼的就是一个12x12的棋盘格,横坐标是2048/1024/.../4/2/1,纵坐标是1~12。在棋盘格的旁边有个显眼的“BIP39”水印。 BIP-39是比特币社区提出的一个行业标准。它的核心作用是将难以记忆的二进制随机数(Entropy)转化为人类可读、易于抄写的单词序列(Mnemonic Words)。这个单词序列还有一个更常见的称呼,叫作“助记词”。 有了助记词,就可以通过一个叫 PBKDF2 的函数进行哈希拉伸,得到一串512位(64字节)的种子(Seed),有了种子之后,由于分层确定性钱包的特性,种子可以派生出“母私钥”,和无数的“子私钥”。简单来说,有了完整的助记词,就能重新找到“钱包”里所有的私钥和公钥,从而实现对资产的操作。 对于一个包含加密货币的钱包助记词,人们肯定会想尽办法安全地储存,毕竟只要被获取助记词,那么钱包的资产就可以被他人随意操作。于是出现了“冷钱包”这个概念,用离线介质来存放私钥或是助记词。最简单的“冷钱包”实现办法就是手动把助记词抄写在某处。 BIP-39定义了2048个单词词表,也就是说,这2048个单词能够代表11bit的二进制数据。也就是说,助记词中的每个单词都可以用11bit的二进制数据来记录。所以有一种“冷钱包”的实现办法就是仅记录助记词对应的12个二进制数据,用无法复原的方式雕刻在棋盘格上,1就是要刻的,0就是不动的。但这种物理方式记录,对于index0的把控就有讲究了,如果什么都不刻,那么是忘了刻呢还是index0呢?这会造成困扰。所以一般这种“冷钱包”是1-base的。也就是说,0号词“abandon”应该刻成二进制的1。 所以第一题就非常简单,通过棋盘格上按照1-base雕刻的二进制数据,可以通过查表找到12个助记词,通过助记词能够还原128bit的Entropy,把Entropy用Hex方式表达后,就能得到“04B4FE31FF55AA55AAF00F55AAAA55FF”,根据谜题的其他信息可以知道,口令就是04B4FE31按照“DEC (8-digit) Big-Endian”的规则转换。 为什么我觉得第一题不完美呢,因为Entropy的后半段都是我自己填充的固定数据,所以,解谜人实际上只需要把前32bit的数据就可以拿到口令,甚至不需要去查BIP-39的词表。 所以,十分懊恼,决定再出一题,这一次,我不会把口令藏在Entropy里面。而是藏在需要计算的那串512位(64字节)的种子(Seed)里面。 我本来的想法是这样的,用脚本随机Entropy,然后算出Seed,再用hashcat按照8位纯数字的规则爆破。看能不能找到一个Entropy,它刷出来的Seed正好全等于某个8位数的SHA-512结果。但这个难度大概是相当于在全宇宙的沙子中找两颗完全一样的沙粒。跑了一会儿就放弃了。 因为红包口令最长可以有18位,所以我又想了另一个办法。用脚本随机Entropy,然后算出Seed,查找Seed的Hex表达中有且仅有一组连续的18位数字。这个就好找很多了,所以又加了一条规则,查找5分钟,找出Seed的Hex表达中数字最少的一个Entropy。 这次“出题”过程非常顺利,接下来就是生成谜题图片,塞钱入红包即可。不过这时候噩耗来了,红包自定义口令的话,不能是纯数字。但我又不想放弃刚才找到的那条18位长的数字巨龙。这时候想到加密货币相关的还有一个Base58编码方式。Base58就是在传统的Base64编码的基础上,删掉了人类容易抄错、认错的几个字符。顺带加了一个“BigInt”的坑。如果解题人直接找一个在线的Base58编解码器去编的话就掉坑里了,因为这类工具都只会把18位长的数字当作字符串来转换。 从难度来说,第二题应该是更难的,不过或许是我的Guidelines写得太详细了,所以难度又大大降低。
03 / 解答与复盘(含剧透)

棋盘格怎么读

每行 12 格,从左到右权重是 2048、1024、512、256、128、64、32、16、8、4、2、1,黑点为 1。把一行读成一个 12 bit 数之后,关键的一步是:减去 1 才是 BIP39 的词索引。 图上写着 1-based(第一题角落那行 amazing → #63 就是提示:BIP39 词表里 0-based 是 62、1-based 才是 63)。因为 1-based 编号要到 2048,才需要第 12 根「2048」支柱——12 列是这么来的。 12 个索引各取 11 bit 拼成 132 bit,按 BIP39 的规矩拆成「前 128 bit = Entropy、后 4 bit = 校验位」,校验位应当等于 SHA256(Entropy) 的头 4 个 bit。这一条可以自证读得对不对。

#!/usr/bin/env python3
"""从 BIP39「冷钱包雕版」棋盘格图里读出 12 个助记词索引。

棋盘格是 12×12 的圆点阵:横轴权重 2048/1024/…/2/1,纵轴行号 1~12,
实心圆 = 1、空心圆 = 0。每行读成一个 12 bit 数,减去 1 才是 BIP39 词索引
(雕版是 1-based 的:index 0 也要刻一格,否则分不清「没刻」和「0 号词」)。
12 个索引各取 11 bit 拼成 132 bit = Entropy(128 bit) + 校验位(4 bit),
校验位应等于 SHA256(Entropy) 的头 4 bit —— 脚本用它自证读对了没有。

依赖:numpy, pillow
用法:
    python read_grid.py 谜面图.jpg
    python read_grid.py 谜面图.jpg --x0 899.5 --y0 459.5 --step 120 --win 20
"""
import argparse
import hashlib
import sys

import numpy as np
from PIL import Image

GRID = 12


def line_centers(dark, axis):
    """找出棋盘上「每 4 列/行一组」的分组粗线中心"""
    proj = dark.sum(axis=1) if axis == 0 else dark.sum(axis=0)
    limit = (dark.shape[1] if axis == 0 else dark.shape[0]) * 0.4
    idx = np.where(proj > limit)[0]
    out, start, prev = [], None, None
    for i in idx:
        if start is None:
            start = prev = i
            continue
        if i > prev + 5:
            out.append((start + prev) / 2)
            start = i
        prev = i
    if start is not None:
        out.append((start + prev) / 2)
    return out


def calibrate(path):
    """只用分组线推格距;原点由第一条线外推半格(线的渲染宽度会引入偏差,故留 --x0 可覆盖)"""
    a = np.array(Image.open(path).convert("L")).astype(np.int32)
    dark = a < 128
    rows, cols = line_centers(dark, 0), line_centers(dark, 1)
    if len(rows) < 2 or len(cols) < 2:
        sys.exit("✗ 没找到棋盘的分组线,请用 --x0/--y0/--step 手工指定")
    ystep = float(np.median(np.diff(rows)) / 4)
    xstep = float(np.median(np.diff(cols)) / 4)
    # 第 0 行/列的圆心:第一条分组线在格子的边界上,圆心在它 + 半格处
    y0 = rows[0] + ystep / 2
    x0 = cols[0] + xstep / 2
    return a, x0, y0, xstep, ystep


def read_bits(a, x0, y0, xstep, ystep, win):
    h = win // 2
    m = np.zeros((GRID, GRID), dtype=int)
    for j in range(GRID):
        for i in range(GRID):
            cx = int(round(x0 + xstep * i))
            cy = int(round(y0 + ystep * j))
            patch = a[cy - h:cy + h, cx - h:cx + h]
            m[j, i] = 1 if patch.mean() < 128 else 0
    return m


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("image")
    ap.add_argument("--x0", type=float, default=None, help="第 0 列圆心 x")
    ap.add_argument("--y0", type=float, default=None, help="第 0 行圆心 y")
    ap.add_argument("--step", type=float, default=None, help="格距(横纵相同)")
    ap.add_argument("--win", type=int, default=20, help="采样窗口边长")
    args = ap.parse_args()

    a, x0, y0, xstep, ystep = calibrate(args.image)
    x0 = args.x0 if args.x0 is not None else x0
    y0 = args.y0 if args.y0 is not None else y0
    if args.step:
        xstep = ystep = args.step

    m = read_bits(a, x0, y0, xstep, ystep, args.win)
    print(f"网格: x0={x0:.1f} y0={y0:.1f} step={xstep:.2f}")
    print()
    for j in range(GRID):
        print("  行%2d  %s  %s" % (j + 1, "".join("●" if v else "·" for v in m[j]),
                                   "".join(str(v) for v in m[j])))

    vals = [int("".join(str(v) for v in m[j]), 2) for j in range(GRID)]
    idx = [v - 1 for v in vals]
    print()
    print("  12bit 刻度值:", vals)
    print("  词索引(减1) :", idx)
    if any(not (0 <= i < 2048) for i in idx):
        sys.exit("✗ 有索引越界,说明读错了(可能列序反了或采样偏了)")

    bits = "".join(f"{i:011b}" for i in idx)
    ent = f"{int(bits[:128], 2):032X}"
    ck = bits[128:]
    want = f"{int(hashlib.sha256(bytes.fromhex(ent)).hexdigest()[0], 16):04b}"
    print("  Entropy(hex) :", ent)
    print("  末 4 bit     :", ck, "| SHA256(Entropy) 前 4 bit:", want,
          "->", "✓ 读法自洽(这是一份合法 BIP39 助记词)" if ck == want else "✗ 不自洽,读错了")


if __name__ == "__main__":
    main()

第 1 题:口令藏在熵里

读出 12 个索引、还原出 128 bit 的 Entropy 之后,谜面右下角的提示框已经把话说完了:左边一格写着「XX XX XX XX / 口令 / HEX ⇔ DEC (8-digit) Big-Endian」,右边一格写着「FF 55 AA 55 AA F0 0F 55 AA AA 55 FF / 固定数据填充区」。 意思是:Entropy 的前 4 个字节是口令区,后 12 个字节是出题时填充的固定数据。把前 4 字节按大端当整数读出来,转成 8 位十进制,就是口令。 他后来复盘时说这一题不完美也在这里:后 12 字节是死数据,解题人只要读出前 32 bit 就能拿到口令,连 BIP39 词表都不必查。完整推导在他 2 月 6 日那条复盘微博里。

第 2 题:数字巨龙与 Base58

第二题的棋盘格布局完全一样,但口令不再放在 Entropy 里。图上的 Guidelines 把链路写死了: 1. mnemonic.to_seed(words),标准盐值、无 passphrase; 2. 扫描 Seed 的十六进制字段,找长度恰好 18 的数字串,且全场只有一条; 3. Final_PWD = Base58(int(Dragon)); 4. 口令是 10 位字母数字串。 三个坑都在细节里:

  • seed 是 64 字节,PBKDF2-HMAC-SHA512、盐值就是字符串 mnemonic、迭代 2048 次,出来的十六进制有 128 个字符,要在里面找那一段连续数字(十六进制里 a~f 会把数字串切断,所以「连续数字」是自然分段的)。
  • Base58 要用大整数编码,不是把 18 位数字当字符串转。这是出题人埋的坑,写在他复盘的末尾:在线 Base58 工具基本都把输入当字符串处理,直接拿去编就错了。
  • 字母表是比特币那套(去掉了 0OIl 之类易混字符),大小写敏感。

按这四条走完,得到的 10 位串就是这一期第二个红包的口令。

验证工具怎么做的

prove.closeai.moe 是他当年写的无后端验证页:页面上没有口令、也没有口令的哈希,只有两段十六进制密文。 原理是把「口令当密钥」反过来用:KEY = SHA256(输入的口令),用它 AES-GCM 解密那段密文;解密成功(GCM 认证标签通过)且明文等于 SUCCESS_VERIFIED,才算对。 它现在依然在线、依然能用,两题的答案都能在上面点出来。代价是它没有做任何归一化——大小写、全角、多余空格都会直接判错,这也是本站做了归一化的原因。

结果

第一个红包 88.88 元 / 20 份,4 小时内被抢光;第二个红包 200 元 / 10 份,2 小时内被抢光。 第 1 题发出三小时后,他在第二题的微博里提了一句进度:上一个红包已有 13/20 破译。

04 / 口令校验

第 1 题 · 88.88 元 / 20 份(2026-02-05 19:39 发布)

8 位纯数字,随便带空格或全角都不影响

第 2 题 · 200 元 / 10 份(2026-02-05 22:09 发布)

10 位字母数字,大小写敏感(当年那个验证工具就区分大小写)