Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 3de9741

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

File tree

21 files changed

+933
-0
lines changed

21 files changed

+933
-0
lines changed

‎README.md‎

Lines changed: 89 additions & 0 deletions
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‎

Lines changed: 9 additions & 0 deletions
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‎

Lines changed: 8 additions & 0 deletions
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‎

Lines changed: 12 additions & 0 deletions
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‎

Lines changed: 21 additions & 0 deletions
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‎

Lines changed: 176 additions & 0 deletions
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‎

Lines changed: 11 additions & 0 deletions
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‎

Lines changed: 12 additions & 0 deletions
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‎

Lines changed: 3 additions & 0 deletions
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‎

Lines changed: 19 additions & 0 deletions
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` 来连接

0 commit comments

Comments
(0)

AltStyle によって変換されたページ (->オリジナル) /