资讯动态

CTF入门:从PIN小游戏到万能爆破脚本的编写思路

发布时间:2026/10/4 3:11:44 来源:尧图企业网站定制
第一次在CTF比赛里签到遇到一道叫《隐藏 PIN 小游戏》的题目终端里黑底白字让你输入一个PIN码错了就回你一句“Wrong PIN”没有次数限制。我当时的第一反应是这也能算CTF后来才意识到这类题就是专门给入门选手练脚本的——你要做的并不是手工去猜而是把它变成一个自动枚举的爆破程序。这篇文章就围绕这个思路展开。我会先拆解这类PIN小游戏的出题逻辑再写出第一版能跑的爆破脚本然后把它升级到远程Web场景最后聊聊“爆破PIN”这个能力怎么迁移到ZIP密码、登录表单、甚至无回显的时间差题目上。适合刚接触CTF、对脚本爆破有点好奇、又不知道从哪下手的读者。内容不涉及复杂利用就是最朴实的一套枚举思路但很多细节都是实战里真会踩到的。1. 先摸清小游戏的底反馈机制决定了爆破脚本怎么写1.1 一道典型签到题的出题逻辑CTF里这类“PIN小游戏”通常有两种形态。第一种是给你一个本地程序比如一个编译过的guess_pin可执行文件或者一个guess_pin.py你运行它之后进入交互模式输入PIN对了就输出flag。第二种是一个远程服务通过TCP端口或者HTTP接口提供同样的功能你发送PIN它返回判断结果。题目方把真正的PIN隐藏在程序内部不让参赛者直接看到源码。反过来如果程序源码就在你手里那就不叫爆破题而是眼力题了。出题人真正想考的东西是什么呢我理解就三件事能不能看懂交互逻辑、能不能写出自动化枚举脚本、能不能处理脚本运行过程中的各种小坑。一个典型的本地题目程序逻辑大致长这样secret 1477 # 题目方预设的隐藏PIN tries 0 while True: tries 1 guess input(PIN: ).strip() if guess secret: print(Correct! flag{you_got_it}) break elif tries 10000: print(No more tries) break else: print(Wrong PIN)注意这里的关键设计PIN是固定的而不是每次运行随机生成。如果每次启动都随机爆破就毫无意义了题目的可解性恰恰建立在“PIN固定、空间有限、反馈稳定”这三条上。懂了这个出题逻辑你就知道脚本应该往哪个方向写。1.2 三种常见反馈模式有回显、半回显、无回显在我习惯的分类里这类题目的反馈机制可以分成三种它们直接决定脚本怎么写反馈模式典型表现爆破脚本怎么判断有回显错误时返回“Wrong PIN”正确时返回“Correct! flag{...}”直接在响应内容里找关键字最简单半回显每次响应内容都一样但响应长度或耗时不同统计响应长度、状态码、返回时间无回显错误和正确都返回同一段固定文本甚至直接断开连接观察连接行为差异或结合时间侧信道有回显是最舒服的因为你的判断逻辑就是一个if flag in output。半回显和无回显就需要动点脑筋比如用响应长度的变化来区分命中与未命中。后面我会专门展开。这里你先记住一个原则写脚本的第一步不是写循环而是搞清楚目标到底把“正确”藏在了哪里。1.3 4位PIN的空间到底有多大为什么爆破可行很多新手听说“爆破”两个字就觉得高大上其实它的本质就是枚举。之所以PIN适合拿来爆破纯粹是因为空间够小。4位纯数字PIN从0000到9999一共10的4次方也就是10000种可能。如果PIN允许十六进制字符0-9和A-F那就是16的4次方65536种。如果是6位纯数字就是100万种。位数每增加一位枚举量直接上一个数量级。具体到时间感受一下。假设本地程序每次交互耗时0.1秒枚举完10000个PIN就是1000秒大约17分钟。但本地交互通常是毫秒级尤其你复用进程的话10000次也就是几十秒的事。就算在网络环境下每次请求0.3秒10000次也不过50分钟可接受。可如果换成6位数字100万次每次0.3秒就是34万秒约94小时这就完全不现实了。所以拿到题目先算一笔账空间在10万以内直接枚举没问题超过百万级先停下来看看有没有别的路比如字典、提示词、或者更聪明的侧信道方法。2. 第一版脚本把“手点一万次”变成一个循环2.1 先观察别急着写代码我第一次做这类题的时候上来就写了个死循环结果跑了好几分钟才发现判断条件写错了。后来养成了一个习惯写爆破脚本之前先手工跟目标交互至少三次。具体来说对本地程序我会先运行起来随便输入几个PIN比如0000、9999、1234观察每次的提示语是否一致、成功提示长什么样、程序是否会在固定次数后退出。对远程目标我会先用curl或者Postman发几个请求看清楚返回内容的长度和特征。这一步花不了30秒但能帮你避免一个致命问题你脚本里用来判定命中的关键词在正确响应里可能根本不存在而你还在傻傻地等它出现。2.2 用subprocess写的“最笨版本”本地程序最直接的自动化方式就是让Python脚本用subprocess去启动目标程序然后把候选PIN作为输入发给它再读取输出。先写最笨的版本逻辑清晰、好调试import subprocess for pin in range(10000): candidate f{pin:04d} p subprocess.Popen( [python3, guess_pin.py], stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderrsubprocess.STDOUT, ) try: out, _ p.communicate(input(candidate \n).encode(), timeout3) except subprocess.TimeoutExpired: p.kill() out, _ p.communicate() if bCorrect in out or bflag in out: print(找到 PIN:, candidate) print(out.decode(errorsignore)) break if pin % 500 0: print(f进度: {pin}/10000)这个版本的思路很简单每个候选PIN都新启动一个进程发送一行输入收集全部输出然后用关键字匹配。它慢但足够可靠。每次启动Python进程大约要几十毫秒10000次跑下来大概几分钟在可控范围内。我建议你先用这个版本跑通前100个候选确认逻辑没有问题再考虑优化。2.3 候选生成与命中判定格式化的细节上面代码里有个细节值得单独说f{pin:04d}。这个格式化的意思是把数字转成4位字符串不足4位用0补齐。0000和0的差别是致命的因为程序大概率会把“0”当成一个无效输入而不是把“0000”当作合法PIN。如果你想写得更传统一点str(pin).zfill(4)的效果是一样的。如果题目允许的字符不止数字比如十六进制PIN那就需要用更通用的候选生成方式from itertools import product chars 0123456789ABCDEF candidates (.join(x) for x in product(chars, repeat4)) for candidate in candidates: print(candidate) # 0000, 0001, ... 000F, 0010 ...命中判定方面我建议不要写死只认一个关键词。正确的做法是先用正确答案试一次把正确输出完整记录下来然后把匹配逻辑封装成一个函数。比如def is_success(output: str) - bool: return Correct in output or flag{ in output这样后续换题的时候只需要改这个函数枚举逻辑完全不用动。2.4 首版脚本的几个经典翻车现场这类题看着简单实际写脚本的时候翻车点比想象中多。我捡几个最常见的说。第一个是编码问题。Windows终端默认用GBK而CTF题目环境里程序输出往往是UTF-8。如果你直接用out.decode()很容易抛UnicodeDecodeError。解决方式是在解码时加errorsignore或者干脆把输出当字节流处理只在做字符串匹配前转一次。第二个是忘记换行。程序里的input()在等你输入一行而input函数只有在收到换行符时才会返回。如果你只发了candidate.encode()而没加\n程序会一直等你直到触发timeout。这一条看起来蠢但我认识的朋友里十个有八个犯过。第三个是stdout和stderr的分流问题。有些程序的报错信息是写到stderr的而错误提示又刚好是你要判断的字段。如果你只读了stdout就会误以为目标没有响应。所以我在代码里写了stderrsubprocess.STDOUT把两个流合并这样不管程序把信息输出到哪都能被读取到。第四个是刷屏问题。如果你在每个循环里都打印当前候选终端会滚得比代码还快真正命中时那条信息早就被冲掉了。我习惯只在每500次或者每1000次打印一次进度命中时再把完整输出单独打出来。3. 升级到远程场景会话、延时与无回显才是真正的门槛3.1 从本地进程到HTTP请求变化的不只是API本地subprocess那套跑通之后很多题目的真正形态是远程服务最常见的就是一个HTTP接口。这时候脚本要换一种交互方式用requests来发请求。写远程爆破脚本前最要紧的事情是用抓包工具或者浏览器开发者工具看一眼请求发到哪个URL、字段名是什么、是POST还是GET、要不要JSON。很多人一上来就猜字段名叫pin结果目标表单里写的其实是pwd或者code白跑半天。一个最基本的远程版脚本长这样import requests url http://127.0.0.1:8000/check for pin in range(10000): candidate f{pin:04d} try: r requests.post(url, data{pin: candidate}, timeout2) except requests.exceptions.RequestException: continue if flag{ in r.text: print(找到 PIN:, candidate) print(r.text) break if pin % 200 0: print(f进度: {pin})这里最大的变化是每次请求都可能因为网络波动、服务端限速、或者DNS解析问题而失败所以我把异常处理加上了。遇到超时或者连接错误直接跳过这个候选不让整个脚本崩溃。3.2 Session、延时和重试别把服务器打炸远程环境跟本地最大的不同是服务端会记住你的行为。很多题目虽然没明说但服务端会在后台记录失败次数一旦你短时间内出错太多它就会返回验证码、临时封禁甚至直接把连接断开。这时候有两点值得做。第一点是用requests.Session()保持会话让每次请求带着相同的Cookie模拟连续交互而不是每次都是陌生人。第二点是在循环里加一点延时比如time.sleep(0.05)把请求频率降下来。这个延时不只是“礼貌”更像是一种自我保护避免触发服务端的限速逻辑。再加一点遇到偶发性的连接错误可以加一个简单的指数退避重试机制import time import requests s requests.Session() s.headers.update({User-Agent: Mozilla/5.0}) def safe_post(pin: str, retries: int 3): for attempt in range(retries): try: return s.post(url, data{pin: pin}, timeout2) except requests.exceptions.ConnectionError: if attempt retries - 1: raise time.sleep(2 ** attempt)指数退避的直觉很简单第一次失败等2秒第二次失败等4秒第三次等8秒给服务端留出恢复时间。3.3 没有“CORRECT”字样时怎么判断命中有回显的题目太仁慈了。现实中很多远程题目的错误响应和正确响应文本内容几乎一样或者你根本不知道正确响应长什么样。这时候我习惯用响应长度作为判断依据。先随便发一个肯定错误的值把它的响应长度记为基准长度。然后枚举过程中如果某个PIN返回的响应长度跟基准不一样那这个响应就有极高概率是命中。代码写起来很简单import requests url http://127.0.0.1:8000/check baseline None for pin in range(10000): candidate f{pin:04d} r requests.post(url, data{pin: candidate}, timeout2) length len(r.content) if baseline is None: baseline length continue if length ! baseline: print(响应长度出现差异:, candidate, length) print(r.text) break这个思路之所以有效是因为服务端在返回错误时通常走一个固定模板长度总是相同的而命中时会带回一段flag长度必然发生变化。用长度判断比用关键词匹配更通用哪怕题目把错误提示改成了一串乱码长度差依然有效。3.4 并发提速本地可以远程先缓缓10000个候选在本地跑很快但远程每次请求要几百毫秒单线程跑完可能要一两个小时。这时候就想到并发。用ThreadPoolExecutor可以把并发逻辑写得很简洁import sys from concurrent.futures import ThreadPoolExecutor, as_completed import requests url http://127.0.0.1:8000/check def check(pin): candidate f{pin:04d} try: r requests.post(url, data{pin: candidate}, timeout2) if flag{ in r.text: return candidate, r.text except requests.exceptions.RequestException: pass return None with ThreadPoolExecutor(max_workers20) as pool: futures {pool.submit(check, pin): pin for pin in range(10000)} for future in as_completed(futures): result future.result() if result: print(找到 PIN:, result[0]) print(result[1]) sys.exit(0)但我要泼一盆冷水并发不是默认选项。本地实验可以开二三十个线程但远程题目目标脆弱高并发可能直接触发风控甚至把比赛环境打挂。我的建议是先单线程跑通确认判断逻辑没问题再试4到8个线程的小并发最后才考虑把线程数加到20以上。另外线程池里不要共享同一个Session因为Session不是线程安全的每个线程用独立的requests.post反而更可靠。4. 把“爆破PIN”的能力迁移到其他CTF题型4.1 ZIP压缩包数字密码同一个枚举逻辑爆破PIN这个技能最直接的应用迁移是ZIP压缩包密码。CTF题目经常把flag打包进一个加密ZIP文件名或者题目描述里给出一条线索暗示密码是4位数字。用Python的zipfile模块可以实现同样的枚举但有一个细节值得注意尽量用read方法读取压缩包内文件而不要用extractall。因为extractall每尝试一个错误密码就会往磁盘写一次文件等你枚举完10000次目录里可能已经躺着几千个垃圾文件了。read只把文件内容读进内存干净得多。import zipfile with zipfile.ZipFile(flag.zip) as zf: for pin in range(10000): pw f{pin:04d}.encode() try: data zf.read(flag.txt, pwdpw) print(密码:, pw.decode()) print(data.decode()) break except Exception: continue密码错误时zipfile在不同Python版本下抛出的异常类型可能不一样有的是RuntimeError有的是NotImplementedError还会有底层的zlib.error。为了避免脚本因为异常类型识别不全而中断我这里直接except Exception虽然写法不够优雅但实战里最省心。4.2 Web登录表单requests加一个小字典如果你已经会了上面这套requests循环那么Web登录弱口令题就只是换了字段名而已。比如一个登录接口需要提交username和password两个字段你要爆破的是密码那么代码骨架完全一样import requests url http://target/login s requests.Session() for pin in range(10000): pw f{pin:04d} r s.post(url, data{username: admin, password: pw}) if welcome in r.text or dashboard in r.text: print(密码:, pw) print(r.text) break这里需要再次强调抓包的重要性。登录成功后的跳转、Set-Cookie里的特殊字段、返回的页面标题都可能比“welcome”这个关键词更可靠。你可以先用一个错误密码拿到失败响应的特征再找一个正确密码拿到成功响应的特征把两者对比之后再来写判定条件。至于Burp Suite的Intruder它确实是图形化爆破利器适合人工操作、观察结果。但自己写Python脚本的优势在于可定制性你可以随意加延时、改判定逻辑、把结果输出到日志里还能在比赛结束之后把脚本保存下来复盘。两个工具不是二选一的关系而是互补。4.3 硬件与杂项里的“PIN”有时答案藏在时间差里这里说一个容易混淆的点。CTF分类里经常出现“PIN”这个词很多时候它指的是个人识别码跟芯片文档里那些VCC、GND引脚定义完全不是一回事。所以你在搜资料的时候看到“USB type A pin脚定义”“DDR4 pin分布图”这类内容基本可以绕开那不是这个方向的东西。但“PIN”出现在硬件或杂项方向时确实有一种更进阶的考法时间差爆破。有些服务端的PIN校验是逐位进行的比如先比较第一位第一位对了才比较第二位错了就立刻返回。这种情况下正确前缀的请求会比错误前缀的请求多算几步响应时间自然更长。利用这个差异可以把爆破从“穷举全部”变成“逐位探测”。思路是对每一个需要探测的位数先固定已知前缀然后对0到9分别发多次请求统计平均耗时耗时最长的那一个大概率就是正确答案。示意代码import time import requests def avg_time(prefix: str, tries: int 5) - float: total 0.0 for _ in range(tries): t0 time.perf_counter() requests.post(url, data{pin: prefix 0}) total time.perf_counter() - t0 return total / tries secret for pos in range(4): best_digit best_time -1.0 for digit in 0123456789: t avg_time(secret digit) if t best_time: best_time t best_digit digit secret best_digit print(当前前缀:, secret)这个思路成立的前提是服务端确实做了逐位校验并且错误时立即返回。如果服务端是把整个PIN做一次哈希比较时间差就不会存在这个方法也就会失效。4.4 一套可以反复用的爆破脚本骨架写多了之后你会发现爆破解法无论目标是什么骨架都差不多。我一般把它拆成三层候选生成产生所有可能的输入单次判定和目标交互判断是否命中主流程循环执行、输出进度、命中后退出一个可以复用的骨架长这样def generate_candidates(): for pin in range(10000): yield f{pin:04d} def check(candidate: str) - bool: # TODO: 在这里写清楚怎么和目标交互以及怎样算命中 raise NotImplementedError def main(): for i, candidate in enumerate(generate_candidates()): if check(candidate): print(命中:, candidate) break if i % 500 0: print(f进度: {i}) if __name__ __main__: main()这套骨架的好处是换了新题目之后你只需要重写generate_candidates和check两个函数。候选生成从纯数字换成十六进制、从4位换成6位判定从找关键字换成比较响应长度主流程一行都不用动。5. 写在最后脚本练习的节奏和两个小习惯5.1 练习节奏从本地到远程再到无回显如果你刚开始接触这类题目我的建议是别一上来就挑战无回显。先把本地程序刷熟练理解subprocess、stdin/stdout、编码这些基础概念然后换成HTTP远程题练习requests、会话保持、超时重试再往后才去碰无回显学习从响应长度和时间差里提取信息。每一步之间跨度不用太大关键是每个阶段都要真的跑通脚本而不是看懂代码就算完事。我自己带过几个新人他们最容易犯的错误是“代码看懂了但自己写就报错”根源就是练得太少细节没有内化。5.2 两个我到现在还在用的小习惯第一个习惯是在写爆破脚本之前先手工发一个错误的请求或者输入把正常失败响应的字节数、关键词、返回时间全部记下来。这个基准值会变成你脚本里的判断依据。没有基准你的判定逻辑就是空中楼阁。第二个习惯是脚本跑出结果之后不要只盯着flag。把flag复制下来同时把脚本保存好在注释里标注题目名、日期、用的方法。这听起来很像“整理笔记”这种无聊的事但当你刷了几十道题之后再回头看会发现这些注释是你最宝贵的方法库。我第一次跑完那个PIN小游戏命中的是1477不是0000也不是9999。那一刻我很清楚地意识到这类题根本没有规律可言它就是单纯考察你愿不愿意把选择交给机器并且能承受一万次循环的无聊。但恰恰是这件无聊的事把我领进了脚本自动化的大门。后来遇到ZIP密码、登录爆破甚至时间差逐位探测我心里想的都是同一个问题候选是什么判定是什么循环怎么写。解题框架一旦搭好剩下的都只是细节。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑