CTFHub SSRF POST请求:gopher协议构造HTTP报文

CTFHub SSRF POST请求:gopher协议构造HTTP报文 CTFHub技能树里的 Web-SSRF POST请求这一关乍看只是 SSRF 系列中平平无奇的一题但真正动手做过的都知道卡在这里的时间往往比前面几关加起来还多。前面几关无非是把 URL 换成本地回环地址、换个伪协议读文件、扫个端口套路都比较直给到了 POST 请求这一关问题一下子变成了服务端确实会替我们发请求可它默认只会发 GET而目标接口偏偏只认 POST。这就逼着我们去研究一个更底层的东西——怎么让一条 HTTP 报文在被当作 URL的情况下完整地、一字不差地送达目标端口。这篇内容就是把这道题从头到尾拆一遍。会讲清楚 SSRF 在这道题里到底被利用在哪一环、为什么 POST 型比 GET 型麻烦、gopher 协议为什么能承担这个任务、报文怎么拼、Content-Length 怎么算、编码该做几层以及我在实操里踩过的那些坑。适合已经了解 SSRF 基本概念、但在 POST 这一关上反复失败的 CTF 新手也适合想借这道题理解协议层构造思路的安全从业者。全程只在自己搭建的靶场或平台提供的练习环境里做这点先说在前面。1. 先把这道题的考点拆明白1.1 SSRF 在这道题里到底被利用在哪一环很多人做 SSRF 题目时习惯性地把它理解成改个 URL 就能读文件这个理解在伪协议读取那一关勉强够用但到了 POST 这一关就不够使了。SSRF 的本质是服务端拿到我们提交的地址后用它自己的身份去发起一次网络请求然后把结果带回给我们。这里有两个关键点一是请求由服务端发出所以它能访问我们访问不到的内网地址二是请求的形态受限于服务端用的那个 HTTP 客户端库。在 CTFHub 这道题里页面通常就是一个输入框提交一个 URL后端拿到后调用类似 curl 的函数去请求这个 URL。我们要做的是让这次请求落到本机的某个服务上并且是以 POST 方法、带上正确的请求体发出去。所以这道题真正考的不是怎么发现 SSRF而是怎么控制请求的方法和内容。理解了这个差别后面的所有操作才有方向。注意SSRF 的练习一定要在可控的靶场、平台题目环境或自己搭的虚拟机里做。未经授权对真实业务发起类似请求性质完全不同。1.2 为什么 POST 请求这一关要单独拎出来如果目标接口接受 GET事情很简单把 URL 写成http://127.0.0.1/flag.php?keyvalue就行。但问题是GET 和 POST 在 HTTP 协议层面的差别不只是参数放哪。GET 把参数塞在请求行的路径里而 POST 需要三段东西同时正确请求行里的方法名、请求头里的Content-Type和Content-Length、以及空行之后的请求体。任何一段缺失或长度对不上服务端要么拒绝要么解析出的参数是空的。而 SSRF 入口通常只允许我们填写一个 URL。URL 里能承载的信息只有协议、主机、端口、路径和查询串。它天然装不下请求头 空行 请求体这种多段结构。这就是矛盾的根源入口只给了 URL 的形态目标却要求完整的报文形态。解决这个矛盾的思路有两条一是找支持自定义请求方法的伪协议二是找一个能把整段原始字节塞进 URL 的协议。前者不现实后者就是 gopher。1.3 判分逻辑与 flag 位置的合理推断CTFHub 的技能树题目判分方式很直接拿到 flag 并提交系统校验通过就算过。所以我们要关注的是 flag 藏在哪、以什么方式取。POST 这一关的常见设计是本机有一个服务只有在收到正确 POST 请求通常带某个特定参数名和值或者干脆只要方法对时才会返回 flag 内容。由此可以反推出攻防的关键路径先确认 SSRF 入口确实存在并且有回显否则我们拿不到服务端请求的结果再确认目标服务的端口和路径然后构造一条完整的 POST 报文最后把服务端返回的内容从回显里捞出来。这四步里第三步是最容易翻车的也是这篇的重点。下面先讲准备。2. 动手前的准备与工具选型2.1 题目入口信息梳理与参数确认进题第一件事不是急着构造 payload而是把入口摸清楚。打开题目页面通常只有一个提交框要求输入 URL。这时候先用最朴素的方式验证 SSRF 是否存在提交http://127.0.0.1/看返回内容里有没有本机服务的页面特征再提交一个明显不通的地址比如一个不存在的端口观察报错信息的差异。这一步的目的有三个。第一确认服务端用的是哪种 HTTP 客户端——从报错文案里往往能看出是 curl 还是其他库这直接决定了 gopher 协议能不能用。第二确认是否有回显也就是服务端请求到的内容会不会原样返回给我们。第三确认过滤规则比如是否禁用127.0.0.1、是否只允许 http 协议。这三个信息拿到手后面构造才有依据。把这三个信息记在一张纸上或者备忘录里别凭记忆。我在做系列题的时候经常来回切窗口一旦搞混了哪关禁了什么就会浪费大量时间在错误的方向上。2.2 为什么一定要先搭一个本地 HTTP 服务这是我认为整个流程里最被低估的一步。很多人上来就对着靶机盲打失败了也不知道是哪一环出问题。正确做法是在本机用一条命令起一个能打印完整请求内容的简易服务先把自己的 payload 发到这个服务上看收到的东西和预期是否一致。用 Python 起一个最简服务只要几行from http.server import BaseHTTPRequestHandler, HTTPServer class Handler(BaseHTTPRequestHandler): def do_POST(self): length int(self.headers.get(Content-Length, 0)) body self.rfile.read(length).decode(utf-8, ignore) print(--- 收到请求 ---) print(self.command, self.path, self.request_version) for k, v in self.headers.items(): print(f{k}: {v}) print(body , repr(body)) self.send_response(200) self.end_headers() self.wfile.write(bok) HTTPServer((127.0.0.1, 8000), Handler).serve_forever()然后拿一个支持 gopher 的客户端比如本机的 curl去发我们拼好的 gopher URL观察打印出来的内容。如果请求行、请求头、请求体三部分都对得上说明 payload 组装无误如果本机测试都过不了那打到靶机上更不可能成功。这一步把协议构造和目标环境两个变量分离开了排查效率会高很多。提示本机验证时建议换个不常用的端口避免和自己电脑上已经在跑的服务撞车。2.3 构造工具手写脚本和现成工具怎么选市面上有 Gopherus 这类现成的 gopher payload 生成工具针对 MySQL、Redis、FastCGI 等都做了模板。对 CTFHub 这道题来说现成工具不是必需的因为这道题的报文结构很简单手写反而更可控。我的建议是先用现成工具对一遍结果理解它的输出长什么样然后自己写一段脚本复现一遍这样遇到变形题才不会抓瞎。自己写脚本的好处在于参数可以灵活调整。比如目标端口从 80 改成 8080、路径从/flag.php改成/api/getflag、请求体从keyvalue改成 JSON改几行字符串就行不用重新找工具。而且脚本里可以直接把 Content-Length 算出来天然避开了手动数字数这一大坑。下面这套流程我全程用 Python 演示。3. 用 gopher 协议构造 POST 请求的完整流程3.1 gopher 协议为什么能发 POST先解释清楚原理不然拼出来的字符串会像天书。gopher 是早期互联网的一种信息检索协议它的 URL 格式是gopher://主机:端口/类型标识选择器。关键点在于当客户端向 gopher 服务端发起连接时它会把选择器这部分内容原样发送过去。换句话说gopher 协议可以理解为TCP 直发——你给什么字节它就往目标端口吐什么字节。这就刚好解决我们的矛盾HTTP 报文本身就是一段有格式的 ASCII 字节流POST /flag.php HTTP/1.1加上请求头和请求体本质上就是一段文本。既然 gopher 可以原样转发字节那我们把整段 HTTP 报文当作选择器塞进 gopher URL服务端用它自己的连接把这段字节送到本机的 80 端口本机上的 Web 服务就收到了一条合法的 POST 请求。还有一点要理解gopher 需要底层 HTTP 客户端支持这个协议。curl 老版本是支持的PHP 的 curl 扩展在编译时如果没禁用也能处理 gopher。这也是为什么前面要先确认服务端用的是哪个客户端——如果是 Python 的 requests 库它是明确不支持 gopher 的这条路就走不通了。3.2 请求报文的逐行组装与 CRLF 处理HTTP 报文的换行不是普通的\n而是\r\n也就是回车加换行。这是 HTTP/1.1 规范里写死的很多服务端解析器对纯\n的容忍度不一样有的能接受有的会直接判定为格式错误。所以在 gopher payload 里每一个换行都必须编码成%0d%0a。一条完整的 POST 报文结构是这样的POST /flag.php HTTP/1.1 Host: 127.0.0.1:80 Content-Type: application/x-www-form-urlencoded Content-Length: 19 Connection: close nameadminpass123按顺序拆开看第一行是请求行包含方法、路径、协议版本三者之间用空格分隔。接下来是若干请求头每行一个键: 值的格式冒号后面习惯留一个空格。然后是一个空行注意这个空行不能省它是请求头和请求体的分隔符很多新手拼字符串时漏掉结果服务端一直等不到请求体最后超时。空行之后才是请求体。Host头是 HTTP/1.1 里必须带的缺了它不少服务端会直接返回 400。Connection: close是我的习惯加上它可以让服务端在响应后主动断连避免我们这边一直挂着等。Content-Type建议写全application/x-www-form-urlencoded是表单提交最常见的类型。3.3 Content-Length 算错就前功尽弃这是这道题最大的坑没有之一。Content-Length必须精确等于请求体的字节长度。注意是字节数不是字符数。如果请求体里有中文一个汉字在 UTF-8 下占 3 个字节用len()在 Python 里直接数字符个数就会算错。正确做法是先把字符串编码成 bytes 再取长度。我见过不少人的失败原因是这样的请求体写的是keyvalue一共 9 个字符但手写的时候写成了 8 或者 10服务端读的时候要么少读一个字符要么等着读第 10 个字节直到超时。表现出来就是请求明明发出去了但服务端没有任何正常响应。这个现象特别迷惑人因为从我们这边看请求确实送出去了。用脚本生成就完全不会有这个问题body nameadminpass123 length len(body.encode(utf-8)) # 用字节长度别用字符长度如果请求体里带中文或者特殊符号这个写法就能自动算对。如果非要手算记住公式是所有字符的 UTF-8 字节数之和ASCII 字符算 1中文算 3。我个人的经验是只要涉及非 ASCII 内容一律交给代码算手算迟早出错。3.4 整段 payload 的 URL 编码策略拼好的报文里包含空格、冒号、换行等字符直接塞进 URL 是不行的必须做 URL 编码。规则很简单除了极少数安全字符其他一律转成%XX形式。空格转%20\r\n转%0d%0a。但要注意编码的顺序和次数这里有两个层次。第一层是把报文本身编码让它可以合法地放进 gopher URL。第二层是把这个 gopher URL 作为参数值提交给题目页面时浏览器或者框架可能还会再编码一次。如果第一层编完直接提交没反应可以试试做两次编码。我一般用 Python 的urllib.parse.quote并且把safe参数设成空字符串这样除了字母数字和_.-~其余全部编码最省心import urllib.parse host, port, path 127.0.0.1, 80, /flag.php body nameadminpass123 payload ( fPOST {path} HTTP/1.1\r\n fHost: {host}:{port}\r\n Content-Type: application/x-www-form-urlencoded\r\n fContent-Length: {len(body.encode(utf-8))}\r\n Connection: close\r\n \r\n f{body} ) encoded urllib.parse.quote(payload, safe) print(fgopher://{host}:{port}/_{encoded})运行这段脚本输出的那一长串就是可以直接提交的 URL。注意/_这两个字符斜杠后面跟的下划线是 gopher URL 里的类型标识表示文本类型这个下划线本身不会被发送到目标端口它是给客户端解析用的。提示这里有个经典怪现象。在某些环境下_之后的第一个字节会被客户端吞掉表现出来就是服务端收到的请求首行变成了OST /flag.php少了个 P。遇到这种情况在_后面多补一个无害字符比如再写一个下划线就能对上。这不是你的 payload 写错了是解析器的历史遗留行为。3.5 提交与回显确认的完整记录脚本跑完拿到那串 gopher URL把它贴进题目的输入框提交。如果前面每一步都对页面回显里应该会直接出现 flag 内容或者出现包含 flag 的响应页面片段。提交之后如果没拿到别急着改 payload先做三件事。第一检查输入框里的 URL 有没有被浏览器或页面脚本改动比如有些前端会把%再处理一遍导致编码被破坏。第二用同样的 payload 打到本机自己起的那个服务上确认请求内容确实是对的。第三确认目标端口和路径写对了80 和 8080 差一位结果完全不同。这三步做完基本能定位到问题在哪一层。我在实际做的时候第一次失败就是因为路径写成了/flag而目标服务实际在/flag.php改过来之后一次就过了。这种低级错误在做题的时候特别常见不是技术问题是细心问题。4. 常见问题速查与排查思路4.1 三类典型现象分别指向哪里把失败现象分成三类定位起来快得多。第一类是提交后页面完全没有变化也没有报错。这种多半是 gopher 协议根本没被支持客户端直接忽略了这次请求或者服务端在发起连接前就失败了。排查方向是回头看报错信息确认客户端类型。第二类是页面有响应但内容是空的或者是一段报错页面。这说明连接建立了请求发出去了但报文格式有问题。重点查 Content-Length 是否匹配、空行有没有漏、换行是否用了%0d%0a。第三类是请求超时。这通常意味着服务端在等待更多数据也就是 Content-Length 的值比实际请求体大它在等那个永远不来的字节。把长度改对就能解决。现象最可能的原因优先排查项无任何变化协议不被支持客户端类型、gopher 是否可用返回空或报错页报文格式错误空行、换行符、请求头拼写请求超时长度不匹配Content-Length 是否偏大首行缺字符gopher 解析差异_后补占位字符这张表我贴在笔记里每次都对照着看比盲目试错效率高很多。4.2 两个失败案例的完整复盘案例一请求体是usernameadminpassword123456长度是 33我手写的时候数成了 32。结果服务端一直在等第 33 个字节最后超时页面转了两秒多才返回。当时我以为是网络问题反复重试了好几次。后来把 payload 打到本机服务上打印出来的Content-Length和实际 body 长度一对比问题一目了然。教训就是别手数长度。案例二目标路径写的是/flag.php但请求行的协议版本我写成了HTTP/1.0。服务端收到后行为异常返回了一个不完整的响应。改成HTTP/1.1就正常了。虽然理论上 1.0 也应该能处理但不同实现的兼容性确实存在差异遇到诡异的返回先把协议版本统一成 1.1 试试。这两个案例的共同点是问题都不在 SSRF 本身而在 HTTP 报文的细节。所以这道题表面考 SSRF实际考的是你对 HTTP 协议报文结构的熟悉程度。4.3 进阶思路协议替换与绕过尝试如果目标环境确实不支持 gopher还有别的路可以想。一是看看有没有其他能承载自定义报文的协议比如某些环境下的dict协议虽然它主要用于字典服务但特定场景下也有奇效。二是回到 GET 思路上看目标接口是否接受把参数放在查询串里有时候 POST 只是习惯GET 也能拿到同样的结果。再一个思路是转到其他 SSRF 利用方式。CTFHub 的 SSRF 技能树里POST 请求这一关之后还有上传文件、FastCGI、Redis 等关卡那些关卡的核心也是 gopher 打内部服务。把这道题的报文构造思路吃透后面几关其实是同一套方法的延伸只是目标服务的报文格式换了。这也是为什么我建议这道题一定要自己手写一遍而不是复制粘贴别人的 payload。提示任何绕过尝试都只在授权环境中进行。拿到真实业务上即使技术上可行也可能造成实际影响。5. 实操心得与可迁移的收获5.1 几个我反复踩到的坑第一个坑是编码层级搞混。有些题目的前端会对输入做一次解码导致你在本地编码好的 payload 到服务端就变了样。判断方法是把提交后的 URL 抓下来看看到底经过了哪些处理。如果发现编码被吃掉一层就补一层编码。第二个坑是路径大小写和扩展名。/flag.php和/Flag.php在不同系统上行为不同Linux 区分大小写Windows 不区分。做题时按题目给的信息来别想当然。第三个坑是端口选择。默认 80 端口不一定对有些题目把服务放在 8080 或其他端口上。判断方法是用 SSRF 扫一下本机常见端口看哪个有响应。这一步在端口扫描那一关有专门练习思路是通用的。第四个坑是忽略了本机验证这一步直接对着靶机反复试。靶机环境比本机复杂出了问题不好定位。养成本地先跑通、再打远端的习惯能省下大量时间。5.2 这套思路能迁移到哪些场景往近了说CTFHub SSRF 系列的后续关卡基本都在用同一套方法。FastCGI 那关是把 POST 报文换成了 FastCGI 的二进制协议包Redis 那关是换成 Redis 的 RESP 协议命令行。核心都是用 gopher 把一段自定义字节送到内部端口只是目标服务的协议不同。把这道题的报文字节结构、长度计算、编码层级这三件事弄明白后面就是换模板。往远了说这套东西在真实的漏洞挖掘和代码审计里也有对应。审计时看到代码里用curl_exec或者类似的函数处理用户可控的 URL就要多想一步这个 URL 的协议有没有被限制如果没限制gopher 能不能用内部有没有只对本机开放的服务这些问题串起来就是一个完整的利用链思路。最后分享一个我自己的小习惯每次拼完 payload我都会先用打印的方式把它翻译回人类可读的报文肉眼过一遍请求行、请求头、空行、请求体四部分是否齐全。这个动作花不了十秒钟但能挡掉大部分低级错误。gopher payload 那一长串百分号编码看起来像天书正因为如此更值得在提交前还原一次看个明白。