LANCTF
LANCTF 是 Lancet 举办的校内 CTF 比赛,出题人包含了绝大多数 Lancet 核心成员。由于面向校内,难度以中等偏下为主。
本人咸甚,在比赛中仅承担了 LANCTFd(based on CTFd) 的二次开发与维护、部分(简单) Web 题目出题人,在这里记录一下自出题以来的想法以及比赛期间的维护、交流等内容。
Web
UnderGroundCity1
题目设置
题目原型:TWCTF2018 shrine
出原题过于简单(难),为了将难度稳定设置在简单,且符合比赛主题,修改了一下界面(偷了 CTFd 的页面,简化了过滤规则。
flag 设置在 config 中,过滤规则如下
@app.errorhandler(404)
def page_not_found(e):
uurl=request.url
if ">" in request.url or "&" in request.url or "-" in request.url or "%26" in request.url or "%3e" in request.url.lower():
return "FORBIDEN"
if "os" in request.url or "self" in request.url or "system" in request.url:
return "I know what you did! But flag you want is in config."
if "'current_app" in request.url:
uurl= "LANCTF{ .... <br> 抄current_app也要懂ssti才对嘛,你的current_app好像被过滤了"
template = '''
{{% set config='flag is in config, but var config was clear'%}}
......
{{url}}
'''.format(url=uurl)
return render_template_string(template), 404
第一次判断避免反弹 shell,对&
的过滤有些多余,但不影响;第二次判断过滤关键字,是 SSTI 题目常见简单过滤;第三次额外设置防止有直接复制 TWCTF 的 payload。
和简单 SSTI 没什么不同,关于 SSTI 可参考 从SSTI到沙箱逃逸-jinja2,在这里想记一下维护中遇到的小问题。
启动管理
题目直接使用 python 启动,多次莫名退出,于是第一天更换为 gunicorn 启动,但在设置参数时埋下了一个坑,su test -c 'gunicorn -w 4 -b 0.0.0.0:8000 --daemon web:app'
,题目使用四进程启动,导致四个初始化的子进程内置类顺序不同,表现在使用 __subclasses__()[59]
时无法固定子类下标,出现这个错误也是因为出题经验太少。
(看日志发现被打了一堆 request.environ['werkzeug.server.shutdown']()
,直接起 flask 服务默认使用的便是 werkzeug)
发现问题后又切换回 python 启动,却没有思考原因,直接换为 supervisor 启动。在 python3 环境下需要自行配置 conf 文件,于是直接使用 apt install supervisor
,添加 test 用户,copy 如下文件到 /etc/supervisor/conf.d
文件夹,之后开启服务即可。
[program:ssti]
directory = /var/www/html ; 程序的启动目录
command = python3 /var/www/html/web.py ; 启动命令
autostart = true ; 在 supervisord 启动的时候也自动启动
startsecs = 5 ; 启动 5 秒后没有异常退出,就当作已经正常启动了
autorestart = true ; 程序异常退出后自动重启
startretries = 3 ; 启动失败自动重试次数,默认是 3
user = test ; 用哪个用户启动
redirect_stderr = true ; 把 stderr 重定向到 stdout,默认 false
stdout_logfile_maxbytes = 20MB ; stdout 日志文件大小,默认 50MB
stdout_logfile_backups = 20 ; stdout 日志文件备份数
; stdout 日志文件,需要注意当指定目录不存在时无法正常启动
stdout_logfile = /var/log/ssti_stdout.log
第二天才想到 __subclasses__()
的变化与进程数有关,只需要设置成子进程为1即可。(菜
UnderGroundCity2
题目原型:HITB2018 某题
题目设计很明确 哈希长度扩展攻击以及 python 的代码审计,未进行大的变动,稍稍降低了难度。使用 gunicorn 启动。
可参考 hash长度扩展攻击研究
BlackBox AND Easy PHP
题目原型:经典上传题
均对内容进行了过滤,分别使用 preg_replace 移除了 <?
和 ?
,故需使用 <script language="php">system($_GET[0]);</script>
方式进行绕过,对于过滤前者可双写 <<??
。
需要注意的是,在 php7 中 script
标签不再支持。
此外, EasyPHP 设置了一处 json 弱类型,来自 XMAN2018。
if(!isset($_COOKIE['authe'])){
//secret_is_'hash.??????'
$autharr=array(
'role'=>'guest',
'passnum'=>'????????'
);
$auth= json_encode($autharr);
ob_start();
setcookie('authe', $auth);
ob_end_clean();
$_SESSION['isguest']=true;
}else{
$temp=$_COOKIE['authe'];
$data=json_decode($temp);
$num=$data->passnum;
if(json_last_error() != JSON_ERROR_NONE){
echo "json error";
exit();
}
if($num!=="????????"){
for ($i=0; $i < 8; $i++) {
//secret num is random generated that you can't guess
if(!($num[$i]==$secretnum[$i]))
{
echo "random secret num error";
exit();
}
}
if($data->role==='admin'){
$_SESSION['isguest']=false;
}
}
FoodWithPHP
题目原型:n1ctf
大量改动,简化,只保留了文件包含,从文件包含读源码,最后包含 session,从而 getshell。
Re
Father and son
题目逻辑:main 启动,fork 自身,子进程生成 core,设置运行权限,execl 调用 core,父进程 ptrace 修改 core 其中的加密密码,之后子进程输出 flag(前几位)。
__int64 sub_400BAE()
{
__pid_t v0; // eax
unsigned int v2; // [rsp+Ch] [rbp-154h]
char v3; // [rsp+10h] [rbp-150h]
char v4; // [rsp+F0h] [rbp-70h]
__int16 v5; // [rsp+148h] [rbp-18h]
unsigned __int64 v6; // [rsp+158h] [rbp-8h]
v6 = __readfsqword(0x28u);
memset(&v4, 0, 0x58uLL);
v5 = 0;
v0 = fork();
v2 = v0;
if ( v0 == -1 )
exit(1);
if ( v0 )
{
wait(0LL);
if ( ptrace(PTRACE_GETREGS, v2, 0LL, &v3) < 0 )
{
perror("ptrace(GETREGS):");
exit(1);
}
ptrace(PTRACE_POKEDATA, v2, &unk_6020AC, 7LL);
ptrace(PTRACE_CONT, v2, 0LL, 0LL);
wait(0LL);
}
else
{
ptrace(0, 0LL, 0LL, 0LL);
execl("./core", "./core", 0LL);
}
return 0LL;
}
正如上面所看到,ptrace 的使用流程一般是这样的:父进程 fork() 出子进程,子进程中执行我们所想要 trace 的程序,在子进程调用 execl() 之前,子进程需要先调用一次 ptrace,以 PTRACE_TRACEME(0) 为参数。这个调用是为了告诉内核,当前进程已经正在被 traced,当子进程执行 execve() 之后,子进程会进入暂停状态,把控制权转给它的父进程(SIG_CHLD信号), 而父进程在fork()之后,就调用 wait() 等子进程停下来,当 wait() 返回后,父进程就可以去查看子进程的寄存器或者对子进程做其它的事情了。
当系统调用发生时,首先检查了能否获取寄存器 REGS,若可以,便修改内存中的值,当做完这些事情之后,通过调用 ptrace(PTRACE_CONT) ,可以让子进程重新恢复运行。此时 core 之中的内存已被修改。
signed __int64 sub_400914()
{
signed __int64 result; // rax
signed int i; // [rsp+4h] [rbp-Ch]
unsigned int *v2; // [rsp+8h] [rbp-8h]
v2 = (unsigned int *)&unk_6020C0;
for ( i = 0; i <= 3; ++i )
{
sub_400834(v2, &unk_6020A0);
result = 8LL;
v2 += 2;
}
return result;
}
__int64 __fastcall sub_400834(unsigned int *a1, int *a2_6020A0)
{
__int64 result; // rax
unsigned int v3; // [rsp+1Ch] [rbp-24h]
unsigned int v4; // [rsp+20h] [rbp-20h]
unsigned int i; // [rsp+24h] [rbp-1Ch]
signed int v6; // [rsp+28h] [rbp-18h]
v3 = *a1;
v4 = a1[1];
v6 = -478700656;
for ( i = 0; i <= 0xF; ++i )
{
v4 -= (v3 + v6) ^ (16 * v3 + a2_6020A0[2]) ^ ((v3 >> 5) + a2_6020A0[3]);
v3 -= (v4 + v6) ^ (16 * v4 + *a2_6020A0) ^ ((v4 >> 5) + a2_6020A0[1]);
v6 += 1640531527;
}
*a1 = v3;
result = v4;
a1[1] = v4;
return result;
}
可以看到修改了 tea 加密算法的密码。
出题人太秀了(
LanCTFd
LanCTFd 在 CTFd 的基础上为比赛需要测试并修改了一些功能,为了更好的保持性能,使用了 CTFd 提供的所有组件 CTFd+Redis+Mysql。其中的一些小修改已写在 readme 中,bug 已经提交修改啦。
但是在比赛中也发现了一些问题:服务器响应时间过长,在阿里云平台查看监控数据发现平均负载1,双核情况下没有问题,TCP连接数1000,而ESTABLISHED状态的也只有个位数,其他的均为未连接状态,单独重启 CTFd 后,连接数降为100左右,后又持续上升。对于这个问题一直没有想明白,猜测可能是gunicorn 的长连接导致的吧,但是为什么会导致服务器响应时间过长呢?(服务器 2C4G Intel Xeon 2.5 GHz)
在赛后开放的过程中发现了一次宕机的情况,这次连接数达到了1200个,查看 docker ctfd 的连接数,约1200个,基本符合。有两种可能性,一是阿里云的机器限制了 tcp 连接的 ESTABLISHED 状态的上限数量;二是 gunicorn 启动默认配置工作状态为 gevent,次数 worker_connections 默认值为 1000,当进程状态下无法响应更多的连接。从阿里云这边来看显然为后者的问题,但是还没有做测试来证实,而且官方的默认配置也应该不会有这样的问题。不过还是后者的可能性更大。在之后的配置中可以考虑--worker-connections=2000
,并且增加到四个进程-w 4
。
CTFd
整体 CTFd 服务使用 docker-compose 部署,使用三个容器组成内网服务,其中 CTFd 由 gunicorn 管理,整体环境在 Python2.7 下。对 CTFd 的主要更改如下:
- 添加谷歌验证码,支持大陆环境(via ctfd-recaptcha-plugin)
- 修改 SMTP 服务器发信内容,减少被服务器拒绝概率;修复 SMTP 发信返回值未判断的 bug(写本文时官方已在几小时前修复)
- 控制未验证账号重发邮件次数,在 session 中硬编码实现
- 动态积分插件,计分公式适应性修改,适配20人解出时达到最低值,使用自定义公式
- 修复动态积分插件中,隐藏用户提交 flag 后造成分数变化的 bug
- 修复由隐藏用户导致的,普通用户在 profile 中排名与 scoreboard 排名不一致的 bug
目前 bug 都已经提上去了,还是我 PR 的代码太烂了作者要自己写(
部署文件
为了折腾整套服务。并且保证一定的稳定性,不得不了解一下每个参数的作用。
Docker file
这里直接贴略微更改后的镜像文件
FROM python:2.7-alpine
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
# 运行 bash 命令,替换更新源
RUN apk add --update-cache bash
RUN apk update && \
apk add python python-dev linux-headers libffi-dev gcc make musl-dev py-pip mysql-client git openssl-dev libxml2-dev libxslt-dev
# alpine 系统使用 apk 命令,添加部分库,支持验证码插件
WORKDIR /opt/CTFd
#下面的 ADD, COPY, CMD, ENTRYPOINT, RUN 命令的相对目录
RUN mkdir -p /opt/CTFd
COPY requirements.txt .
RUN pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
# 同理,换源
COPY . /opt/CTFd
VOLUME ["/opt/CTFd"]
# VOLUME 指令可以在镜像中创建挂载点,这样只要通过该镜像创建的容器都有了挂载点。通过 VOLUME 指令创建的挂载点,无法指定主机上对应的目录,是自动生成的。需通过 docker inspect 查看。
RUN for d in CTFd/plugins/*; do \
if [ -f "$d/requirements.txt" ]; then \
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r $d/requirements.txt; \
fi; \
done;
RUN chmod +x /opt/CTFd/docker-entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/opt/CTFd/docker-entrypoint.sh"]
# 容器入口点脚本
docker-compose file
官方默认配置如下:
version: '2'
services:
ctfd:
build: .
# 镜像在当前目录创建
restart: always
# 自动重启 no / always / on-failure / unless-stopped
ports:
- "8000:8000"
environment:
- UPLOAD_FOLDER=/var/uploads
- DATABASE_URL=mysql+pymysql://root:ctfd@db/ctfd
- REDIS_URL=redis://cache:6379
- WORKERS=1
- LOG_FOLDER=/var/log/CTFd
- ACCESS_LOG=-
- ERROR_LOG=-
volumes:
- .data/CTFd/logs:/var/log/CTFd
- .data/CTFd/uploads:/var/uploads
- .:/opt/CTFd:ro
# 挂载目录
depends_on:
- db
# 依赖
networks:
# 网络,可见有两个子网
default:
internal:
db:
image: mariadb:10.4
restart: always
environment:
- MYSQL_ROOT_PASSWORD=ctfd
- MYSQL_USER=ctfd
- MYSQL_PASSWORD=ctfd
volumes:
# 挂载以保存数据
- .data/mysql:/var/lib/mysql
networks:
internal:
# This command is required to set important mariadb defaults
command: [mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --wait_timeout=28800, --log-warnings=0]
cache:
image: redis:4
restart: always
volumes:
- .data/redis:/data
networks:
internal:
networks:
# 网络定义,同网络下可访问,使用 service 名称即可,docker 会完成名称解析
default:
internal:
internal: true
# 真内网,不可访问互联网