Skip to content

Commit 3de9741

Browse files
committed
open source
1 parent 196a3bf commit 3de9741

21 files changed

+933
-0
lines changed

README.md

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Hackergame nc 类题目的 Docker 容器资源限制、动态 flag、网页终端
2+
3+
## 快速入门
4+
5+
### 配置证书
6+
7+
证书用于验证用户 Token。请确保这里的证书文件(cert.pem)与 [Hackergame 平台](https://github.com/ustclug/hackergame) 配置的证书相同,这样 Hackergame 平台为每个用户生成的 Token 才可以通过这里的用户验证。
8+
9+
如果你仅仅想测试一下,可以使用 <dynamic_flag/cert.pem> 自带的证书,以及这个 Token:
10+
11+
`1:MEUCIQCjK1QcPFro86w3bKPb5zUZZd96ocp3EZDFcwLtJxNNDAIgEPk3Orw0mE+zHLQA7e31kSFupNtG9uepz2H4EqxlKWY=`
12+
13+
在生产环境中,请使用自己生成的证书,方法如下:
14+
15+
生成私钥 `openssl ecparam -name secp256k1 -genkey -noout -out private.pem`
16+
17+
生成证书 `openssl req -x509 -key private.pem -out cert.pem -days 365`
18+
19+
然后将生成的 `cert.pem` 文件放在 <dynamic_flag/cert.pem>。
20+
21+
### 配置题目
22+
23+
如果你仅仅想测试一下示例题目,那么可以跳过此步骤。
24+
25+
本项目的目录结构设计为可以被 [Hackergame 平台的题目导入命令](https://github.com/ustclug/hackergame/blob/master/frontend/management/commands/import_data.py) 直接导入。
26+
27+
<dynamic_flag> 目录中包含了题目容器化、连接限制、动态 flag 相关的逻辑。
28+
29+
<web_netcat> 目录中包含了网页终端的逻辑。
30+
31+
如果仅仅是使用本项目,那么以上两个目录中的内容都不需要修改,它们也不会被 Hackergame 平台导入(因为没有 `README.md` 文件)。
32+
33+
示例题目在 <example> 目录中,其中的 <example/docker-compose.yml> 中引用了以上两个目录中的内容。你可以把 example 目录复制多份为不同的名字,它们在被导入到 Hackergame 平台后会显示为多道题目。
34+
35+
题目是 Docker 化的,注意每次运行题目 Docker 时**只启动一个题目的实例,通过标准输入输出交互,你的题目不需要监听端口,也不需要做任何资源限制。**参见 <example/Dockerfile> 和 <example/example.py>。
36+
37+
你需要修改 <example/.env> 文件,针对题目的情况进行配置,包括 nc 的端口(`port`)、网页终端的端口(`web_port`)、运行时间和资源限制、flag 文件位置、动态 flag 规则、题目的容器名称等。动态 flag 的生成方法可以由你自己决定,可以使用类似 `'flag{prefix_' + sha256('secret'+token)[:10] + '}'` 的方案,示例中使用了 Python 的 f-string。对于多个 flag 的情况,`flag_path` 中路径和 `flag_rule` 中 Python 表达式都用 `,` 分隔即可。容器名称(`challenge_docker_name`)是 docker-compose 自动命名的,请设置为目录名 + "_challenge"。对于每一个连接,如果 Token 合法并且连接频率没有超过限制,那么你的题目容器会以指定的资源限制启动,动态生成的 flag 会被挂载到指定的路径,选手的 TCP 连接将会被连接到容器的标准输入输出上。如果你的题目需要获得用户 Token,直接读取 `hackergame_token` 环境变量即可。
38+
39+
<example/README.md> 是用于导入 Hackergame 平台的,里面配置的 flag 需要与 `.env` 中配置的 flag 相同,端口也需要进行相应修改。
40+
41+
### 运行题目
42+
43+
在 <example> 目录中运行 `docker-compose up --build` 即可,然后你可以通过 `nc 127.0.0.1 10000` 来连接,也可以使用 <http://127.0.0.1:10001/> 的网页终端。
44+
45+
## 本项目的背景
46+
47+
与很多 CTF 比赛类似,USTC Hackergame 需要运行选手与服务器交互的 nc 类题目。然而 CTF 比赛中常见的做法有以下问题:
48+
49+
- 通过求解 PoW 来做题目的连接限制,对新手不友好,也在某种程度上影响比赛的体验
50+
51+
- pwn 题缺少真正有效的资源限制。我调研了很多开源的 Docker 化方案,也咨询了很多比赛的出题人,结论是现有的方案都无法真正防止针对性的资源耗尽攻击(所谓“搅屎”)。很多 pwn 题的部署方案会限制选手能够使用的命令,这只是增加了资源耗尽攻击的难度而已,并没有从根本上解决问题。
52+
53+
- 动态 flag、监听端口很多时候是题目逻辑的一部分,而我想做到这部分逻辑对出题人是透明的,这样也可以让题目更统一、更稳定。
54+
55+
因为以上提到的原因,我在 Hackergame 2019 前开发了这套系统,并且在 Hackergame 2020 前进行了一些改进,但是这部分代码一直没有开源。
56+
57+
如今,我把这份代码以 MIT 协议开源出来,欢迎大家测试、使用和改进。
58+
59+
## 本项目的功能
60+
61+
- 对用户 Token 进行验证,只有合法的 Token 才可以运行题目
62+
63+
- 根据 Token 中的用户 ID 进行连接频率限制
64+
65+
- 根据 Token 动态生成 flag,并自动挂载进题目 Docker
66+
67+
- 限制题目的资源使用,包括限时、进程数限制、内存限制、不允许联网等
68+
69+
- 保证题目的稳定性和安全性,用户无论在题目 docker 中做什么,都不会影响其他用户做题
70+
71+
- 为题目提供一个网页终端,方便新手直接在网页上尝试做题,配合 Hackergame 平台可以实现 Token 的自动填充。
72+
73+
## 本项目的限制
74+
75+
本项目只适用于每个连接启动一个进程的题目,包括 pwn 题和其他的 nc 连接服务器类题目。
76+
77+
如果你的题目是一个一直运行的应用,例如 flask app,那么请自己进行 Token 的验证和动态 flag 生成。
78+
79+
Token 的验证方法见 <dynamic_flag/front.py> 中的 `validate` 函数。由于 Token 是非对称签名,所以证书和验证代码完全可以公开。
80+
81+
如果不需要进行连接限制,那么不验证 Token 的合法性也无妨。
82+
83+
自己实现对用户的连接限制时,注意请按用户 id 限制,不要按 Token 限制,因为签名系统不保证每个 id 只有唯一的合法签名。
84+
85+
## 已知问题
86+
87+
- 证书是否过期不会被检查
88+
89+
- Windows 系统上可能无法正常使用,Linux 和 macOS 经测试没有问题

dynamic_flag/.env

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
port=2333
2+
conn_interval=10
3+
token_timeout=30
4+
challenge_timeout=30
5+
pids_limit=16
6+
mem_limit=128m
7+
flag_path=/home/ctf/flag
8+
flag_rule=f"flag{{welcome_{sha256('secret'+token)[:10]}}}"
9+
challenge_docker_name=test_challenge_ctf

dynamic_flag/Dockerfile

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
FROM ustclug/debian:10
2+
RUN apt update && apt -y upgrade && \
3+
apt install -y xinetd python3-openssl docker.io && \
4+
rm -rf /var/lib/apt/lists/*
5+
COPY xinetd /etc/xinetd.d/ctf
6+
COPY front.py /
7+
COPY cert.pem /
8+
CMD ["xinetd", "-dontfork"]

dynamic_flag/cert.pem

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIByjCCAXCgAwIBAgIUTVIbMFuhApks+IlLfPLsIV0rw9cwCgYIKoZIzj0EAwIw
3+
PDELMAkGA1UEBhMCQVUxCjAIBgNVBAgMAScxITAfBgNVBAoMGEludGVybmV0IFdp
4+
ZGdpdHMgUHR5IEx0ZDAeFw0yMTA0MTgxNjU4MzRaFw0yMjA0MTgxNjU4MzRaMDwx
5+
CzAJBgNVBAYTAkFVMQowCAYDVQQIDAEnMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRn
6+
aXRzIFB0eSBMdGQwVjAQBgcqhkjOPQIBBgUrgQQACgNCAAREnpLsdtmenQf0Iw2Z
7+
5xLOgDYa9VpLU3C1Gxm9TpJi4eAaX8kPpYVkD1rsjE9SOt6/GLnYRTytrlJOGQ/X
8+
nL5Ao1MwUTAdBgNVHQ4EFgQU3BPqL8FbENPzF1rj00aMFzyXXjAwHwYDVR0jBBgw
9+
FoAU3BPqL8FbENPzF1rj00aMFzyXXjAwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjO
10+
PQQDAgNIADBFAiAT/QceAhSZRkiqLh6Udhey2etTr7L08b+G6k2r8HSfswIhAM7Y
11+
TEh3QVp8F5UvzO5g/OtTb0/gS41kvY8OU8AbMI8T
12+
-----END CERTIFICATE-----

dynamic_flag/docker-compose.yml

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
version: '2'
2+
services:
3+
front:
4+
build: .
5+
ports:
6+
- ${port}:2333
7+
restart: always
8+
read_only: true
9+
ipc: shareable
10+
volumes:
11+
- /var/run/docker.sock:/var/run/docker.sock
12+
environment:
13+
- hackergame_conn_interval=${conn_interval}
14+
- hackergame_token_timeout=${token_timeout}
15+
- hackergame_challenge_timeout=${challenge_timeout}
16+
- hackergame_pids_limit=${pids_limit}
17+
- hackergame_mem_limit=${mem_limit}
18+
- hackergame_flag_path=${flag_path}
19+
- hackergame_flag_rule=${flag_rule}
20+
- hackergame_challenge_docker_name=${challenge_docker_name}
21+
- TZ=Asia/Shanghai

dynamic_flag/front.py

+176
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import base64
2+
import OpenSSL
3+
import os
4+
import time
5+
import fcntl
6+
import signal
7+
import tempfile
8+
import hashlib
9+
import atexit
10+
import subprocess
11+
from datetime import datetime
12+
13+
tmp_path = "/dev/shm/hackergame"
14+
tmp_flag_path = "/dev/shm"
15+
conn_interval = int(os.environ["hackergame_conn_interval"])
16+
token_timeout = int(os.environ["hackergame_token_timeout"])
17+
challenge_timeout = int(os.environ["hackergame_challenge_timeout"])
18+
pids_limit = int(os.environ["hackergame_pids_limit"])
19+
mem_limit = os.environ["hackergame_mem_limit"]
20+
flag_path = os.environ["hackergame_flag_path"]
21+
flag_rule = os.environ["hackergame_flag_rule"]
22+
challenge_docker_name = os.environ["hackergame_challenge_docker_name"]
23+
readonly = int(os.environ.get("hackergame_readonly", "1"))
24+
25+
with open("cert.pem") as f:
26+
cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, f.read())
27+
28+
29+
def validate(token):
30+
try:
31+
id, sig = token.split(":", 1)
32+
sig = base64.b64decode(sig, validate=True)
33+
OpenSSL.crypto.verify(cert, sig, id.encode(), "sha256")
34+
return id
35+
except Exception:
36+
return None
37+
38+
39+
def try_login(id):
40+
os.makedirs(tmp_path, mode=0o700, exist_ok=True)
41+
fd = os.open(os.path.join(tmp_path, id), os.O_CREAT | os.O_RDWR)
42+
fcntl.flock(fd, fcntl.LOCK_EX)
43+
with os.fdopen(fd, "r+") as f:
44+
data = f.read()
45+
now = int(time.time())
46+
if data:
47+
last_login, balance = data.split()
48+
last_login = int(last_login)
49+
balance = int(balance)
50+
last_login_str = (
51+
datetime.fromtimestamp(last_login).isoformat().replace("T", " ")
52+
)
53+
balance += now - last_login
54+
if balance > conn_interval * 3:
55+
balance = conn_interval * 3
56+
else:
57+
balance = conn_interval * 3
58+
if conn_interval > balance:
59+
print(
60+
f"Player connection rate limit exceeded, please try again after {conn_interval-balance} seconds. "
61+
f"连接过于频繁,超出服务器限制,请等待 {conn_interval-balance} 秒后重试。"
62+
)
63+
return False
64+
balance -= conn_interval
65+
f.seek(0)
66+
f.truncate()
67+
f.write(str(now) + " " + str(balance))
68+
return True
69+
70+
71+
def check_token():
72+
signal.alarm(token_timeout)
73+
token = input("Please input your token: ").strip()
74+
id = validate(token)
75+
if not id:
76+
print("Invalid token")
77+
exit(-1)
78+
if not try_login(id):
79+
exit(-1)
80+
signal.alarm(0)
81+
return token, id
82+
83+
84+
def generate_flags(token):
85+
functions = {}
86+
for method in "md5", "sha1", "sha256":
87+
88+
def f(s, method=method):
89+
return getattr(hashlib, method)(s.encode()).hexdigest()
90+
91+
functions[method] = f
92+
93+
if flag_path:
94+
flag = eval(flag_rule, functions, {"token": token})
95+
if isinstance(flag, tuple):
96+
return dict(zip(flag_path.split(","), flag))
97+
else:
98+
return {flag_path: flag}
99+
else:
100+
return {}
101+
102+
103+
def generate_flag_files(flags):
104+
flag_files = {}
105+
for flag_path, flag in flags.items():
106+
with tempfile.NamedTemporaryFile("w", delete=False, dir=tmp_flag_path) as f:
107+
f.write(flag + "\n")
108+
fn = f.name
109+
os.chmod(fn, 0o444)
110+
flag_files[flag_path] = fn
111+
return flag_files
112+
113+
114+
def cleanup():
115+
if child_docker_id:
116+
subprocess.run(
117+
f"docker rm -f {child_docker_id}",
118+
shell=True,
119+
stdout=subprocess.DEVNULL,
120+
stderr=subprocess.DEVNULL,
121+
)
122+
for file in flag_files.values():
123+
os.unlink(file)
124+
125+
126+
def create_docker(flag_files, id):
127+
cmd = (
128+
f"docker create --init --rm -i --network none "
129+
f"--pids-limit {pids_limit} -m {mem_limit} --memory-swap -1 --cpus 1 "
130+
f"-e hackergame_token=$hackergame_token "
131+
)
132+
133+
if readonly:
134+
cmd += "--read-only "
135+
136+
if challenge_docker_name.endswith("_challenge"):
137+
name_prefix = challenge_docker_name[:-10]
138+
else:
139+
name_prefix = challenge_docker_name
140+
141+
timestr = datetime.now().strftime("%m%d_%H%M%S_%f")[:-3]
142+
child_docker_name = f"{name_prefix}_u{id}_{timestr}"
143+
cmd += f'--name "{child_docker_name}" '
144+
145+
with open("/proc/self/cgroup") as f:
146+
for line in f:
147+
if "/docker/" in line:
148+
docker_id = line.strip()[-64:]
149+
break
150+
prefix = f"/var/lib/docker/containers/{docker_id}/mounts/shm/"
151+
152+
for flag_path, fn in flag_files.items():
153+
flag_src_path = prefix + fn.split("/")[-1]
154+
cmd += f"-v {flag_src_path}:{flag_path}:ro "
155+
156+
cmd += challenge_docker_name
157+
158+
return subprocess.check_output(cmd, shell=True).decode().strip()
159+
160+
161+
def run_docker(child_docker_id):
162+
cmd = f"timeout -s 9 {challenge_timeout} docker start -i {child_docker_id}"
163+
subprocess.run(cmd, shell=True)
164+
165+
166+
if __name__ == "__main__":
167+
child_docker_id = None
168+
flag_files = {}
169+
atexit.register(cleanup)
170+
171+
token, id = check_token()
172+
os.environ["hackergame_token"] = token
173+
flags = generate_flags(token)
174+
flag_files = generate_flag_files(flags)
175+
child_docker_id = create_docker(flag_files, id)
176+
run_docker(child_docker_id)

dynamic_flag/xinetd

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
service ctf
2+
{
3+
server = /usr/bin/python3
4+
server_args = -u /front.py
5+
port = 2333
6+
protocol = tcp
7+
type = UNLISTED
8+
user = root
9+
wait = no
10+
flags = NODELAY
11+
}

example/.env

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
port=10000
2+
web_port=10001
3+
nc_host=front
4+
nc_port=2333
5+
conn_interval=10
6+
token_timeout=30
7+
challenge_timeout=300
8+
pids_limit=16
9+
mem_limit=256m
10+
flag_path=/flag1,/flag2
11+
flag_rule=f"flag{{this_is_an_example_{sha256('example1'+token)[:10]}}}",f"flag{{this_is_the_second_flag_{sha256('example2'+token)[:10]}}}"
12+
challenge_docker_name=example_challenge

example/Dockerfile

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
FROM python:3.9
2+
COPY example.py /
3+
CMD ["/usr/local/bin/python3", "-u", "/example.py"]

example/README.md

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
enabled: true
3+
name: 示例题目
4+
category: general
5+
url: http://127.0.0.1:10001/?token={token}
6+
prompt: flag{...}
7+
index: 0
8+
flags:
9+
- name: flag1
10+
score: 100
11+
type: expr
12+
flag: f"flag{{this_is_an_example_{sha256('example1'+token)[:10]}}}"
13+
- name: flag2
14+
score: 200
15+
type: expr
16+
flag: f"flag{{this_is_the_second_flag_{sha256('example2'+token)[:10]}}}"
17+
---
18+
19+
除了网页终端,你也可以通过 `nc 127.0.0.1 10000` 来连接

example/docker-compose.yml

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
version: '2'
2+
services:
3+
challenge:
4+
build: .
5+
entrypoint: ["/bin/true"]
6+
front:
7+
extends:
8+
file: ../dynamic_flag/docker-compose.yml
9+
service: front
10+
depends_on:
11+
- challenge
12+
web:
13+
extends:
14+
file: ../web_netcat/docker-compose.yml
15+
service: web

example/example.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
print("Your first flag:", open("flag1").read())
2+
print("Answer the question to get your second flag")
3+
if input("1+1=").strip() == "2":
4+
print(open("flag2").read())
5+
else:
6+
print("Wrong!")

web_netcat/.env

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
nc_host=bbs.ustc.edu.cn
2+
nc_port=23
3+
web_port=3000

web_netcat/.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules
2+
app.js

0 commit comments

Comments
 (0)