CTF实战:当铺密码的快速破解与自动化脚本编写

在CTF(Capture The Flag)竞赛的密码学赛道上,你永远不知道下一个挑战会以何种形式出现。有时是复杂的现代加密算法,有时则是隐藏在历史尘埃中的古典密码。当铺密码,这个听起来颇具江湖气息的加密方式,就曾多次在国内外各大CTF赛事中“闪现”,让不少初次接触的选手感到困惑。它不像RSA那样有严谨的数学基础,也不像AES那样有标准的实现库,其核心规则简单到令人意外——仅依赖于汉字笔画的“出头”数量。然而,正是这种基于汉字特性的“土法”加密,在比赛的高压环境下,往往成为区分选手观察力与工具化能力的关键点。本文将带你深入当铺密码的内核,不仅理解其原理,更侧重于分享一套从快速识别、手动破解到编写高效Python自动化脚本的完整实战流程,让你在下次遇到类似题目时,能从容不迫地拿下分数。

1. 当铺密码的核心原理与快速识别

当铺密码并非一种广为人知的标准化密码体系,它更像是一种在特定群体(如旧时当铺用以记录数字)或现代CTF出题人创意下诞生的“隐写”式替换密码。其核心加解密规则,用一句话就能概括:

每个汉字中,笔画延伸超出主体框架(即“出头”)的数量,即为该汉字所代表的数字。

这个定义需要拆解来看。首先,什么是“出头”?在汉字书写中,许多笔画在收笔时会超出字的主体结构。例如,“王”字,其最上面一横、中间一横、最下面一横以及中间一竖,这些笔画的末端都明显超出了字的中心区域,我们可以清晰地数出有4个出头。同理,“夫”字,上面两横出头,下面一撇一捺也出头,合计4个出头。因此,“王”和“夫”在当铺密码中都代表数字4。

为了快速在比赛中识别出当铺密码,你需要关注以下几个特征:

  1. 密文形式:密文通常是一串看似无意义的、排列整齐的汉字。这些汉字之间可能有空格分隔,也可能连续书写。它们往往不是一句通顺的话,而是单个汉字的堆砌。
  2. 汉字选择:出题人倾向于选择结构清晰、出头数易于辨认的汉字。像“王”、“夫”、“井”、“由”、“中”、“大”、“口”、“工”等笔画简单的字是高频选项。
  3. 数字范围:由于一个汉字的出头数有限,通常代表的数字在0到10之间(极少数复杂字可能更多)。因此,解密后得到的数字序列,很可能对应着ASCII码(0-127)、flag的十进制表示或坐标等信息。

下面是一个快速对照表,收录了CTF题目中最常见的当铺密码汉字:

汉字 出头笔画数 代表数字 记忆技巧
0 0 全封闭,无出头
1 1 中间一竖向上出头
2 2 中间一竖上下两头都出头
2 2 撇捺两笔都算出头
2 2 上下两横出头
4 4 三横一竖,共四端出头
4 4 类似“王”,但中间是撇捺
4 4 两横两竖,四端出头
3 3 一横、一撇、一捺,共三出头
0 0 全封闭,无出头(注意与“由”区别)

掌握这个表,你就能在几秒钟内完成大部分简单题目的心算解密。例如,看到“由中井”,立刻可以反应出是“1 2 4”。

2. 手动破解实战:从密文到Flag的完整推演

在CTF比赛中,时间就是分数。当铺密码题目往往不是终点,而是获取下一步关键信息(如密钥、坐标、另一段密文)的跳板。因此,建立一套快速、准确的手动破解流程至关重要。我们通过一个模拟赛题来演练。

假设你遇到的题目描述如下:

我们在一个古老当铺的账本上发现了如下记录,据说其中隐藏着通往下一关的秘钥:王夫 井工 夫口 由中人 井中 夫夫 由中大

步骤一:拆分与初步观察 首先,将密文按空格或视觉分组拆分成独立的汉字单元:王夫井工夫口由中人井中夫夫由中大。这看起来像是两两或三三一组。

步骤二:逐字翻译为数字 运用上一节的快速对照表,或直接观察笔画出头数:

  • :4笔出头 -> 4
  • :4笔出头 -> 4 因此 王夫 -> 44
  • :4 -> 4
  • :2 -> 2 因此 井工 -> 42
  • :4 -> 4
  • :0 -> 0 因此 夫口 -> 40
  • :1 -> 1
  • :2 -> 2
  • :2 -> 2 因此 由中人 -> 122
  • :4 -> 4
  • :2 -> 2 因此 井中 -> 42
  • :4 -> 4
  • :4 -> 4 因此 夫夫 -> 44
  • :1 -> 1
  • :2 -> 2
  • :3 -> 3 因此 由中大 -> 123

现在我们得到一串数字序列:44, 42, 40, 122, 42, 44, 123

步骤三:数字序列的常见转换 CTF中,数字序列最常见的转换目标是ASCII字符。ASCII码中,可打印字符的范围大致在32-126之间。我们观察这组数字:44, 42, 40, 122, 42, 44, 123。它们全部落在可打印ASCII范围内。

步骤四:转换为字符 将每个十进制数视为ASCII码,转换为对应的字符:

  • 44 -> ,
  • 42 -> *
  • 40 -> (
  • 122 -> z
  • 42 -> *
  • 44 -> ,
  • 123 -> {

得到字符串:,*(z*,{。这看起来不像有意义的flag。

步骤五:尝试另一种分组(关键步骤) 回顾最初的密文王夫 井工 夫口 由中人 井中 夫夫 由中大,我们是一个汉字对应一个数字。但有没有可能,题目中的“王夫”是作为一个整体代表一个两位数“44”,而不是两个独立的数字“4”和“4”?在ASCII中,44是逗号,这不太像flag开头。标准的flag格式通常是flag{CTF{

让我们检查数字序列:44, 42, 40, 122, 42, 44, 123。如果把它们看作ASCII码,67(C), 84(T), 70(F), 123({)... 这正是常见的CTF{格式!我们重新审视:

  • 王夫 -> 两个4,是“44”还是“4”和“4”?如果“王夫”整体代表数字“44”,那它离67很远。但如果“王”代表6,“夫”代表7呢?等等,这不符合出头规则。

这里就是陷阱所在,也是实战经验有些题目中,汉字代表的数字就是其出头数,但最终数字需要作为整体十进制数转换为ASCII,而不是单个数字拼接。但我们得到的44,42,40...显然不是67,84,70。

换个思路:也许“王夫”不是44,而是“王”=6,“夫”=7?这需要重新数出头。实际上,“王”字标准写法(楷体)是4出头,但有些手写体或变体可能被出题人认定为不同的出头数。在CTF中,出题人可能自定义或微调规则。一个经典的已知flag格式是flag{,其ASCII是102,108,97,103,123。我们看看序列:44,42,40,122,42,44,123。完全对不上。

这时,我们需要考虑最经典的当铺密码例题(恰好与输入中原始内容一致)。已知密文“王夫 井工 夫口 由中人 井中 夫夫 由中大”对应的明文数字是“67 84 70 123 82 77 125”。这正好对应ASCII:C(67), T(84), F(70), {(123), R(82), M(77), }(125),即CTF{RM}

为什么? 因为在这个约定俗成的例题中,“王夫”两个字合起来代表数字67。如何从“王夫”得到67?规则是:分别计算每个字的出头数,然后将这两个数字并置(concatenate),而不是相加。“王”出头数是6?“夫”出头数是7?我们数一下:“王”字,在有些计数方式下(特别是关注“竖”笔上下两端都明显超出横笔时),可以被认为是6个出头(三横六端?不,通常是4个)。这里存在歧义。实际上,在这套特定编码里:

  • -> 6
  • -> 7
  • -> 8
  • -> 4
  • -> 0
  • -> 1
  • -> 2
  • -> 3
  • -> 5

按照这个映射,“王夫”(6和7)并置为67,“井工”(8和4)并置为84,以此类推。这提醒我们一个至关重要的实战技巧:当标准规则不通时,立即尝试题目可能附带的提示或已知的经典编码表,甚至暴力枚举0-9的映射关系。

手动破解的最后一步,就是将得到的ASCII码转换为字符,得到最终flag:CTF{RM}。这个过程锻炼了你对数字敏感度、ASCII转换和规则试探的能力。

3. 构建自动化破解Python脚本

手动破解适合简单题目,但遇到密文较长、映射关系复杂或需要集成到更复杂解题流程时,自动化脚本是唯一选择。我们的目标是编写一个健壮、灵活、可交互的当铺密码解密工具。

首先,我们需要一个基础映射字典。基于经典例题和常见汉字,我们可以构建如下:

# 基础当铺密码映射表(经典例题版)
classic_pawnshop_dict = {
    '口': 0,
    '由': 1,
    '中': 2,
    '人': 3,
    '工': 4,
    '大': 5,
    '王': 6,
    '夫': 7,
    '井': 8,
    '田': 9,
    # 可以根据需要扩展更多汉字
}

但CTF出题人可能会改变映射。因此,一个更通用的脚本应该允许用户自定义映射,或者自动尝试所有可能的数字组合。下面是一个功能更全面的脚本框架:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

def decrypt_pawnshop_classic(ciphertext, custom_map=None):
    """
    使用经典或自定义映射解密当铺密码。
    :param ciphertext: 密文字符串,如"王夫 井工"
    :param custom_map: 自定义的汉字-数字映射字典,若为None则使用经典映射
    :return: 解密后的数字列表和ASCII字符串
    """
    # 默认经典映射
    default_map = classic_pawnshop_dict

    mapping = custom_map if custom_map else default_map

    # 移除空格,将密文视为纯汉字序列,或按空格分组处理
    # 这里我们处理两种格式:无空格连续汉字,或有空格分组的汉字
    if ' ' in ciphertext:
        groups = ciphertext.split(' ')
    else:
        # 如果没有空格,假设每个汉字独立(或需要其他方式分组,这里按单字处理)
        groups = list(ciphertext)

    numbers = []
    for group in groups:
        num_str = ''
        for char in group:
            if char in mapping:
                num_str += str(mapping[char])
            else:
                # 遇到未知汉字,用'?'占位,实践中可能需要更复杂的处理
                print(f"警告:字符 '{char}' 不在映射表中,已忽略。")
                num_str += '?'
        if num_str:  # 避免空字符串
            numbers.append(int(num_str)) if '?' not in num_str else numbers.append(num_str)

    # 尝试将数字转换为ASCII字符
    ascii_result = ''
    for num in numbers:
        if isinstance(num, int) and 0 <= num <= 1114111:  # Unicode范围
            try:
                # 优先尝试常见ASCII范围
                if 32 <= num <= 126:
                    ascii_result += chr(num)
                else:
                    ascii_result += f'[{num}]'  # 非可打印字符用数字表示
            except ValueError:
                ascii_result += f'[{num}]'
        else:
            ascii_result += str(num)

    return numbers, ascii_result

if __name__ == '__main__':
    # 示例密文
    cipher = "王夫 井工 夫口 由中人 井中 夫夫 由中大"
    print(f"密文: {cipher}")

    # 使用经典映射解密
    nums, text = decrypt_pawnshop_classic(cipher)
    print(f"解密数字: {nums}")
    print(f"ASCII转换: {text}")

    # 演示自定义映射(例如,另一种可能的解释)
    custom_dict = {'王': 1, '夫': 2, '井': 3, '工': 4, '口': 5, '由': 6, '中': 7, '人': 8, '大': 9}
    nums2, text2 = decrypt_pawnshop_classic(cipher, custom_dict)
    print(f"\n使用自定义映射解密数字: {nums2}")
    print(f"自定义映射ASCII转换: {text2}")

这个脚本提供了基础功能。但在真实CTF环境中,我们可能需要一个交互式暴力枚举工具,因为映射表可能是未知的。下面是一个进阶思路的脚本,它尝试将解密出的数字序列与常见flag格式进行匹配:

def brute_force_pawnshop(ciphertext, max_digit_per_char=2):
    """
    暴力尝试所有可能的数字映射(假设每个汉字代表0-9的数字)。
    此函数仅用于短密文和概念演示,实际组合数巨大。
    :param ciphertext: 密文(去除空格)
    :param max_digit_per_char: 假设每个汉字最多代表几位数(通常为1)
    """
    from itertools import product
    chars = list(set(ciphertext.replace(' ', '')))
    print(f"密文中唯一汉字: {chars}")

    # 为每个唯一汉字生成所有可能的数字(0-9)
    possibilities = {}
    for char in chars:
        possibilities[char] = list(range(10))  # 0-9

    # 这是一个指数级复杂度的操作,仅适用于极少量汉字
    # 此处省略具体枚举代码,仅说明思路:
    # 1. 生成所有可能的映射组合
    # 2. 对每种映射,解密得到数字串
    # 3. 将数字串按可能的分组长度(如2位一组)转换为ASCII
    # 4. 检查转换结果中是否包含'flag', 'CTF', '{', '}'等关键词
    # 5. 输出所有可能的有意义结果

    print("暴力枚举需要根据具体题目调整策略,例如已知部分明文或flag格式。")

# 更实用的方法是,如果题目给了例子,可以自动推导映射。
def deduce_mapping_from_example(cipher_part, number_part):
    """
    根据已知的密文-数字对,推导映射关系。
    例如:cipher_part="王夫", number_part=67 -> 可能映射为 {'王':'6', '夫':'7'}
    """
    cipher_part = cipher_part.replace(' ', '')
    num_str = str(number_part)
    if len(cipher_part) != len(num_str):
        print("警告:密文长度与数字位数不符,可能不是简单的一一对应。")
        # 可能需要考虑分组并置规则
    mapping = {}
    for c, n in zip(cipher_part, num_str):
        mapping[c] = int(n)
    return mapping

# 使用示例
cipher_sample = "王夫"
number_sample = 67
deduced_map = deduce_mapping_from_example(cipher_sample, number_sample)
print(f"从示例推导的映射: {deduced_map}")

编写脚本时,务必注意异常处理日志输出,以便在调试时快速定位问题。将脚本模块化,解密函数、映射管理、输出格式化分开,方便集成到更大的CTF解题工具链中。

4. 实战技巧与高阶应用场景

掌握了原理和基础工具后,如何在赛场上更快、更准地解决当铺密码题?以下是一些凝结了实战经验的技巧。

技巧一:观察密文结构,预判转换类型

  • 如果密文汉字数量不多(如小于10个),且排列整齐,很可能每个汉字或每两个汉字直接对应一个ASCII码。
  • 如果密文汉字数量很多,可能每若干个汉字代表一个数字,这些数字需要进一步处理(如作为坐标、索引等)。
  • 注意密文中是否有重复出现的汉字。例如,“夫夫”连续出现,很可能代表同一个数字重复,或代表两位数如“77”。观察重复模式有助于猜测映射。

技巧二:利用Flag格式进行约束 绝大多数CTF flag具有固定格式,如flag{...}CTF{...}SECCON{...}等。这些格式的ASCII码是已知的。

  • f -> 102
  • l -> 108
  • a -> 97
  • g -> 103
  • { -> 123

因此,你可以从密文开头寻找可能对应102, 108, 97, 103, 123的汉字或汉字组合。这能极大缩小映射的搜索空间。例如,如果密文开头是“田由中大”,你可以尝试计算哪种映射能让这四个字对应的数字并置后是102或67等。

技巧三:结合其他古典密码或编码 当铺密码很少单独出现。它常与以下形式结合:

  1. 与摩斯电码、银河字母等组合:解密出的数字串,可能还需要进行二次解码。例如,数字串可能是摩斯电码的另一种表示(点划转换为01),或者直接是银河字母的索引。
  2. 作为其他密码的密钥:解密出的文本,可能是一个单词或短语,用作维吉尼亚密码、Playfair密码的密钥。
  3. 隐藏在图片或文件中:密文可能以图片水印、文件注释、网络流量中的异常字段等形式出现,需要你先用StegSolve、binwalk、Wireshark等工具提取出来。

技巧四:编写通用解码函数并集成到工具库 不要每次比赛都重写脚本。将当铺密码的解密函数封装好,放入你的个人CTF工具库(例如一个Python模块crypto_utils.py)。这个函数应该支持多种输入格式和参数:

def decode_pawnshop(cipher, mapping='classic', group_by=1, output_format='ascii'):
    """
    通用当铺密码解码器。
    :param cipher: 密文字符串
    :param mapping: 映射表,可以是'classic',或自定义字典
    :param group_by: 每几个汉字为一组数字(默认为1,即每个汉字独立数字)
    :param output_format: 输出格式,'ascii', 'decimal', 'hex'
    :return: 解码后的字符串
    """
    # 实现逻辑...
    pass

这样,在比赛中,你只需要几行导入和调用,就能快速得到结果,把节省下来的时间用于更复杂的挑战。

技巧五:注意非典型汉字和变体 出题人有时会使用一些出头数不明确的汉字,或者采用异体字、繁体字。例如,“龟”字出头数是多少?这可能会引起争议。如果遇到这种情况:

  1. 首先尝试最常见的解释。
  2. 如果不行,考虑出题人可能使用了某个特定字体下的笔画数。
  3. 在团队赛中,可以分工尝试不同的计数方式。
  4. 终极方法:如果密文不长,可以尝试暴力枚举每个汉字可能代表的数字(0-9),然后检查所有输出中是否有可读的flag格式字符串。这可以编写一个简单的脚本自动完成。

最后,记住CTF密码学题目的核心是思维灵活性。当铺密码本身并不复杂,但它考验的是你能否跳出常规思维,理解出题人设定的“规则”,并将这种规则快速转化为自动化操作。把本文介绍的手动技巧和脚本模板加入你的兵器库,下次再在赛场上看到那排神秘的汉字时,你就能会心一笑,知道分数已经到手了。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐