前言
关于使用docker来部署题目的过程一直很不清楚,网上相关的资料也很琐碎和欠缺,因此在此记录相关的知识,感谢@V0n大哥的指教!
Docker
功能
Docker技术可以用虚拟机技术来理解,同样也利用了虚拟化的技术,只不过它属于轻量化的虚拟化。
虚拟机通过一个.iso文件来保存一台物理机的信息,通过读取.iso文件就可以模拟一台子电脑,只要内存足够就可以在一台物理机同时运行多个子电脑,而且子电脑之间相互隔离互不影响。但虚拟机技术的缺点就在于占用空间太大、启动慢,有时候我们需要的仅仅是虚拟机的某个环境(如PHP+MySQL+Apache环境),但是启动一整台子电脑却因此要同时运行整个windows或Linux系统,如果能够只运行我们所需要的这个环境那就能节省很多空间了。
而Docker的容器技术恰好就解决了这个问题,它不需要模拟整个操作系统,只需要虚拟一个小规模的环境。容器的优点很明显:启动时间很快、占的空间也很小,同时又能满足我们对指定环境的需求。
Docker在CTF出题时的优势也很明显:
- 靶机是在一个虚拟容器中而不是在真实的服务器上,这样就能一定程度上(并不完全)确保服务器的安全
- 可以通过做题者每次启动容器时来分配CTF平台所生成的随机flag
- 复现题目时会比较方便
Docker的三个基础概念:
-
镜像(image):类似于虚拟机的镜像,是一个包含有文件系统的面向Docker引擎的只读引擎。任何应用程序运行都需要环境,而镜像就是用来提供这种运行环境的。例如:Ubuntu镜像就是一个包含Ubuntu操作系统环境的模板,Apache镜像就是一个安装Apache服务的模板。
-
容器(container):类似于一个轻量级的沙盒,可以看作一个极简的Linux系统环境(包括root权限、进程空间、用户空间等),以及运行在其中的应用程序。Docker引擎利用容器来运行、隔离各个应用。容器是镜像创建的应用实例,可以创建、启动、停止、删除容器,各个容器相互隔离、互不影响。
-
仓库(repository):类似于代码仓库,这里是镜像仓库,是Docker存放镜像文件的地方。
对这三者关系的理解十分重要,以下是我个人的理解:
首先我们在本地配好一个环境(如PHP+MySQL+Apache环境、JDK环境等),如果要在另外一台机器的话就要重新配这个环境,显然这样重复的工作不是我们喜欢的。因此有人发明了镜像的技术,将我们配好的环境保存起来作为一个镜像。这样在另外一台机器只要重新将镜像的内容映射在一个特殊的空间中(意味着这个特殊的空间就拥有了我们想要配好的那个环境),这个特殊的空间就称为容器。
如果我们配好的不止有一种环境,例如有多个PHP环境、多个JDK环境等,那么就可以把这些不同类别的环境所对应的镜像也归为不同类别,仓库就是用来存放不同类别的镜像(仓库1存放多个不同的PHP环境镜像、仓库2存放多个不同的JDK环境镜像)。当然以上只是一个例子,完全可以根据自己的指标来建立有区别意义的仓库。
安装
可以到Docker官网安装,网上的教程都很全面,不在此赘述。
启动docker:systemctl start docker
重启docker:systemctl restart docker
关闭docker:systemctl stop docker
将docker添加为用户组可以参考文章
常用命令
以下指令会在后文用到:
docker pull [镜像名]
:搜索并拉取(下载)别人建好的镜像docker images
:查看本地的image镜像docker run -d -p [服务器端口]:[docker内部端口] [镜像名]
:根据指定的镜像名创建并启动一个容器,同时指定了端口信息,该指令会返回该容器的ID,便于之后对该容器执行其他操作docker ps
:查看正在运行的容器docker ps -a
:查看所有容器docker cp [物理机的文件路径] [容器ID]:[容器中的文件路径]
:将本地文件复制到容器指定路径中docker start [容器ID]
:启动指定容器docker stop [容器ID]
:停止指定容器docker rm [容器ID]
:删除指定容器,前提是容器应该处于停止状态docker rmi [镜像名]
:删除指定镜像,前提是该镜像所映射的容器都被删除了
使用Docker来部署题目
以最简单的PHP代码审计类的题目入手,在var/www/html目录下有index.php和flag.php文件。
index.php:
<?php
include('flag.php');
$md51 = md5('QNKCDZO');
$a = $_GET['a'];
$md52 = md5($a);
if(isset($a)){
if ($a != 'QNKCDZO' && $md51 == $md52) {
echo $flag;
} else {
echo "false!!!";
}
}
else{
echo "please input a";
}
show_source(__FILE__);
flag.php:
<?php
$flag="flag{123}";
思路1
-
第一步:需要配有Linux+PHP+Apache环境的镜像
-
第二步:将该镜像映射到一个容器中(同时启动容器),将该容器的内部的80端口(Apache服务)与物理机的某端口关联,也即访问物理机的该端口就会访问该容器的80端口,如果服务器是阿里云的还需要开放防火墙安全组。
-
第三步:将题目所用到的两个文件复制到容器中的web服务目录下(var/www/html)
下面是该思路所对应的做法:
第一步:首先tutum/lamp
配置了Linux+Apache+MySQL+PHP环境,是别人已经实现并封装好的镜像,所以可以直接用拉取镜像指令:
docker pull tutum/lamp
第二步:将该镜像映射到一个容器中,并将本机的1234端口与容器的80端口相关联:
docker run -d -p 1234:80 tutum/lamp
可以获取到该容器ID:
查看正在运行的容器来验证:
在阿里云设置安全组,确保开放1234端口(后文不再展示):
此时访问服务器的1234端口验证:
第三步:
使用文件复制指令将题目的两个文件复制到web服务目录:
docker cp flag.php 3079b99128e9:/var/www/html/
docker cp index.php 3079b99128e9:/var/www/html/
刷新页面可以验证:
测试完毕之后为了不浪费资源,关闭该容器,并删除该容器:
docker stop 3079b99128e9
docker rm 3079b99128e9
此时再刷新页面:
思路2
思路1虽然容易理解,步骤也简单,但是可以看出很局限。拿建房子来类比,我们是在别人建好的房子中(tutum/lamp所映射的容器)添加自己的一些家具(index.php和flag.php)。假设我们要修改的是房子的本身(如配置php.ini中的一些信息,或者构建数据库等),那么这一步就很难实现了。
因此如果一开始不需要整个房子,而是房子的一部分,然后根据自己的需要再添加一些环境把房子建好(这里并不指完全是自己从零开始构建,还是会用到别人封装好的镜像,只是相对于思路1那样直接拉取lamp整个框架的镜像而言的,但如果是大神的话那也可以随意从零开始),就可以解决思路1的局限性。
这时我们希望能将建房子的步骤记录下来(可以称为房子的模板),需要建房子的时候再用这个模板对应生成一个房子的镜像,然后映射到一个容器上即可把房子建出来。这里房子的模板的作用在Docker中对应的就是Dockerfile的功能了。
Dockerfile
Docker可以在Dockerfile中读取指令并自动构建一个镜像,它是一个文本文件,其中包含用户可以在命令行上调用进行镜像装配的所有命令,对它的功能理解见上文。
Dockerfile的指令格式为:instruction arguments
,类似于Linux指令形式:instruction
为不同的指令,arguments
为instruction
对应要处理的参数。
常用的指令有如下几种:
-
FROM [镜像名]
:指定用于在构建过程中所需要的基础镜像(也即别人封装好的一些环境),因此一般必须写在最前面,在DockerHub上有非常多高质量的官方镜像,有可以直接拿来使用的服务类镜像(如nginx、redis、mysql、tomcat)、操作系统类镜像(如ubuntu、debian、centos、alpine)等,如:FROM nginx
-
RUN [命令行]
:用于执行命令行命令,使用的频率很高,如:RUN echo '123456' >test.txt
-
COPY [物理机的文件路径] [容器中的文件路径]
:将主机中的文件复制到对应容器的文件路径,如:COPY /src /test/
-
ENV
:指定一个环境变量并赋值,其他指令就可以使用该环境变量了 -
EXPOSE [端口号]
:映射到宿主机的端口,这个命令主要体现在启动容器时所执行的命令,docker run -d -p 1234:80 tutum/lamp
中的80端口就是EXPOSE
所暴露的端口
在这个平台有一些题目的Docker项目,尝试拿其中一道题目0ctf_2016_unserialize的Dockerfile来分析:
FROM php:5.6-fpm-alpine
COPY files /tmp/
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories \
&& apk add --update --no-cache nginx mysql mysql-client \
&& docker-php-source extract \
&& docker-php-ext-install mysql \
&& docker-php-source delete \
&& mysql_install_db --user=mysql --datadir=/var/lib/mysql \
&& sh -c 'mysqld_safe &' \
&& sleep 5s \
&& mysqladmin -uroot password 'root' \
&& mysql -e "source /tmp/db.sql;" -uroot -proot \
&& mkdir /run/nginx \
&& mv /tmp/nginx.conf /etc/nginx/nginx.conf \
&& mv /tmp/vhost.nginx.conf /etc/nginx/conf.d/default.conf \
&& mv /tmp/src/* /var/www/html \
&& chmod -R -w /var/www/html \
&& chmod -R 777 /var/www/html/upload \
&& chown -R www-data:www-data /var/www/html \
&& rm -rf /tmp/* \
&& rm -rf /etc/apk
EXPOSE 80
FROM 语句:Alpine 操作系统是一个面向安全的轻型 Linux 发行版,常用于Docker镜像。指定PHP5.6版本的环境,且开启fpm配置。
COPY语句和EXPOSE语句容易理解,就不再赘述了
RUN语句:
-
sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
调用了sed流编辑器命令,这一句的作用就是换为国内镜像源,将dl-cdn.alpinelinux.org地址更改为mirrors.ustc.edu.cn,sed命令其他用法可参考文章
-
apk add --update --no-cache nginx mysql mysql-client
调用了apk命令,apk是软件包管理工具,是由Alpine提供的,这一句的作用就是安装最新的依赖包nginx、mysql、mysql-client。apk命令其他用法可参考文章
-
docker-php-source extract docker-php-ext-install mysql docker-php-source delete
调用了docker关于php扩展命令,这三句的作用就是让php开启mysql扩展,其他用法可参考文章
-
mysql_install_db --user=mysql --datadir=/var/lib/mysql sh -c 'mysqld_safe &' sleep 5s mysqladmin -uroot password 'root' mysql -e "source /tmp/db.sql;" -uroot -proot
与mysql数据库相关的操作,包括新建数据库、设置数据库用户名和密码、导入数据库数据db.sql
-
mv /tmp/nginx.conf /etc/nginx/nginx.conf mv /tmp/vhost.nginx.conf /etc/nginx/conf.d/default.conf
用于更改nginx配置
-
mv /tmp/src/* /var/www/html chmod -R -w /var/www/html chmod -R 777 /var/www/html/upload chown -R www-data:www-data /var/www/html rm -rf /tmp/* rm -rf /etc/apk
对题目文件进行一些操作
经过Dockerfile执行以下语句,就可以制作我们的专属镜像了:
docker build -t [镜像的名称] .
注意最后还有一个.
接着再通过生成的镜像来生成容器即可,这就是思路2,但是更加专业点的还需要使用到docker-compose来实现。
docker-compose
docker-compose是用于定义和运行多容器Docker应用程序的工具,结合docker-compose.yml文件来配置应用程序需要的所有服务,可以从docker-compose.yml文件配置中创建并启动所有服务。
安装
docker-compose可以用curl指令来安装,也可以用pip指令安装。
个人尝试使用前者方式失败了,使用后者比较方便:
pip install docker-compose
执行之后再验证docker-compose能否正常运行:
docker-compose -version
中途遇到一些报错,相关的解决方案(如果安装顺利可以直接跳过):
报错1
发生在执行第一条指令:
ERROR: Cannot uninstall 'subprocess32'. It is a distutils installed project and thus we cannot accurately determine which files belong to it which would lead to only a partial uninstall.
先全局搜索与subprocess32
相关的文件:
find / -name *subpro*.egg-info
找到之后删除该文件即可:
rm -rf /usr/lib64/python2.7/site-packages/subprocess32-3.2.6-py2.7.egg-info
报错2
发生在执行第二条指令:
Traceback (most recent call last):
File "/bin/docker-compose", line 5, in <module>
from compose.cli.main import main
File "/usr/lib/python2.7/site-packages/compose/cli/main.py", line 18, in <module>
import docker.errors
File "/usr/lib/python2.7/site-packages/docker/__init__.py", line 2, in <module>
from .api import APIClient
File "/usr/lib/python2.7/site-packages/docker/api/__init__.py", line 2, in <module>
from .client import APIClient
File "/usr/lib/python2.7/site-packages/docker/api/client.py", line 5, in <module>
import requests
File "/usr/lib/python2.7/site-packages/requests/__init__.py", line 43, in <module>
import urllib3
File "/usr/lib/python2.7/site-packages/urllib3/__init__.py", line 10, in <module>
from .connectionpool import (
File "/usr/lib/python2.7/site-packages/urllib3/connectionpool.py", line 31, in <module>
from .connection import (
File "/usr/lib/python2.7/site-packages/urllib3/connection.py", line 45, in <module>
from .util.ssl_ import (
File "/usr/lib/python2.7/site-packages/urllib3/util/__init__.py", line 4, in <module>
from .request import make_headers
File "/usr/lib/python2.7/site-packages/urllib3/util/request.py", line 5, in <module>
from ..exceptions import UnrewindableBodyError
ImportError: cannot import name UnrewindableBodyError
先将urllib3
库删除,再重新安装:
pip uninstall urllib3
pip install urllib3
重新安装时无法实现:
Looking in indexes: http://mirrors.aliyun.com/pypi/simple/
Requirement already satisfied: urllib3 in /usr/lib/python2.7/site-packages (1.10.2)
解决办法是用yum指令来安装:
yum install python-urllib3
报错3
发生在解决报错2之后,重新执行查询版本的指令时:
/usr/lib/python2.7/site-packages/requests/__init__.py:91: RequestsDependencyWarning: urllib3 (1.10.2) or chardet (2.2.1) doesn't match a supported version!
RequestsDependencyWarning)
Traceback (most recent call last):
File "/bin/docker-compose", line 5, in <module>
from compose.cli.main import main
File "/usr/lib/python2.7/site-packages/compose/cli/main.py", line 18, in <module>
import docker.errors
File "/usr/lib/python2.7/site-packages/docker/__init__.py", line 2, in <module>
from .api import APIClient
File "/usr/lib/python2.7/site-packages/docker/api/__init__.py", line 2, in <module>
from .client import APIClient
File "/usr/lib/python2.7/site-packages/docker/api/client.py", line 5, in <module>
import requests
File "/usr/lib/python2.7/site-packages/requests/__init__.py", line 113, in <module>
from urllib3.exceptions import DependencyWarning
ImportError: cannot import name DependencyWarning
跟上一步类似,先将requests
库删除:
pip uninstall requersts
这次直接用yum指令重新安装:
yum install python-requests
成功安装:
其他问题可以尝试参考文章。
docker-compose.yml文件
我们把要启动的容器镜像、分配的端口号、环境变量等信息写入这个文件中,接着工具docker compose就可以根据这个文件来启动一个容器。以hctf2018 warmup的yaml(跟yml一样)文件为例:
version: "2"
services:
web:
build: .
image: glzjin/hctf_2018_warmup
ports:
- "0.0.0.0:8081:80"
environment:
- FLAG=flag{test}
restart: always
version说明了yml文件指明的版本号,一般我们使用2,3这两个版本。
services就是yml文件的主体,定义了服务了配置。里面的web标签是我们自己定义的。
build表明了以dockerfile类型启动一个容器,后面跟的是dockerfile的路径,支持相对路径和绝对路径,在这个yml文件里面,表明dockerfile与yml处在同个目录下。
- 容器的启动也可以根据已有的镜像,如果定义了image这个标签,就会从本地搜寻相关镜像构建容器,如果本地找不到相关的镜像,就会从网上数据库搜寻相关的镜像。但大家可能会产生疑问了,这里我们定义了build还有image两个不同的标签来构建镜像,那么容器到底要用build还是image来构建呢,这种情况下将按照dockerfile的方式来构建镜像,并且把镜像的名称定义为image标签里面的名称。
- environment标签构建了相关的环境变量,在这里我们是定义了一个FLAG环境变量,并且值为flag{test},这个地方在后面有大用处,我们后面再说
- ports标签定义了映射的端口,0.0.0.0:8081表示映射到本机的8081端口,后面的80端口则要与Dockerfile文件中EXPOSE的端口保持一致。
通过执行指令即可开启容器:
docker-compose up -d
使用
使用指令启动容器,再验证是否启动成功:
docker-compose up -d
docker ps
此时访问服务器的8081端口验证是否正常:
动态生成flag
关于动态Flag的实现,一般是通过CTFd的平台插件,为每一个容器生成一个独立的flag,并把这个flag写入环境变量中(这步出题人无需费心)。我们要做的就是要把这个生成的flag替换掉我们题目环境中比如flag.txt,flag.php里面原来的flag。 我们可以用sed命令对文件中的字符串进行替换. 例如我们的flag文件是flag.php,那么我们只要在原来的文件中写为:
<?php
flag="FLAG";
>
那么我们只需要执行
sed -i 's/FLAG/flag{test}/' /var/www/html/flag.php
就可以把flag.php中的”flag”替换成flag{test}
那么我们的思路就很明确了,我们写一个docker-php-entrypoint文件,每次启动容器时运行这个文件,用环境变量中随机生成的flag替换掉flag.php中的flag.就可以成功实现目的了。
还是以0ctf_2016_unserialize为例,编写docker-php-entrypoint:
#!/bin/sh
sed -i "s/flag{0ctf_2016_unserialize_is_very_good!}/$FLAG/" /var/www/html/config.php
export FLAG=not_flag
FLAG=not_flag
修改Dockerfile:
FROM php:5.6-fpm-alpine
LABEL Author="Virink <virink@outlook.com>"
LABEL Blog="https://www.virzz.com"
COPY files /tmp/
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories \
&& apk add --update --no-cache nginx mysql mysql-client \
&& docker-php-source extract \
&& docker-php-ext-install mysql \
&& docker-php-source delete \
&& mysql_install_db --user=mysql --datadir=/var/lib/mysql \
&& sh -c 'mysqld_safe &' \
&& sleep 5s \
&& mysqladmin -uroot password 'root' \
&& mysql -e "source /tmp/db.sql;" -uroot -proot \
&& mkdir /run/nginx \
&& mv /tmp/docker-php-entrypoint /usr/local/bin/docker-php-entrypoint \
&& mv /tmp/nginx.conf /etc/nginx/nginx.conf \
&& mv /tmp/vhost.nginx.conf /etc/nginx/conf.d/default.conf \
&& mv /tmp/src/* /var/www/html \
&& chmod -R -w /var/www/html \
&& chmod -R 777 /var/www/html/upload \
&& chown -R www-data:www-data /var/www/html \
&& rm -rf /tmp/* \
&& rm -rf /etc/apk
EXPOSE 80
CMD ["/bin/sh", "-c", "docker-php-entrypoint"]
此时Dockerfile和原版的区别就是增添了对docker-php-entrypoint的处理。此时用了CMD指令来进行处理,CMD指令与RUN类似,都是可以执行命令的语句,只不过RUN语句执行在容器构建之时,CMD语句执行在容器构建之后,这里之所以不能用RUN语句,是因为此时容器还没够构建,环境变量里面还没有FLAG这个变量。用RUN语句无法正确执行替换语句。
此时我们再启动容器:
docker-compose up -d
查看config.php的内容:
可以看到config.php中的内容,已经被我们替换为docker-compose.yml中环境变量里面的flag.。至此,我们已经实现了将环境变量中的FLAG替换掉题目环境中的flag,每次启动容器都会由系统平台随机生成环境变量flag,通过自启动docker-php-entrypoint实现了flag的替换,也就达到了动态flag的效果。
一个比较好用的镜像
从上面的过程中,我们看到对于一道题目来说,除了源码以外,最大的不方便之处就是还要有相关的nginx文件配置,在这里我推荐virink写的base_image_nginx_mysql_php_56来辅助我们快速出题。 这个镜像主要好在不需要我们去配置其他的nginx设置,而且还支持自动导入db.sql文件,支持自动执行flag.sh文件。
如果题目中主要是需要配置数据库,我们只需要src文件夹中放入源码、flag.sh、db.sql(flag.sh、db.sql文件名不能变),现在我们dockerfile只需要这么写:
FROM ctftraining/base_image_nginx_mysql_php_56
COPY src /var/www/html
RUN mv /var/www/html/flag.sh / \
&& chmod +x /flag.sh
这个镜像就会自动为我们配置相关的nginx文件,同时自动导入要执行的db.sql文件来配置数据库(默认用户为root,密码也为root,执行后db.sql会被删掉)
同时镜像还会自动执行flash.sh来从环境变量中读取flag写入到flag.php中(需要注意的是,flag.sh必需在根目录下,也就是我们需要执行mv /var/www/html/flag.sh /这一步的原因所在)。 可以看到,这个镜像极大的方便了我们对Dockerfile的编写,把Dockerfile简化到只需要两三句话就能搞定。
如果是要在php7的环境下出题,可以采用base_image_nginx_mysql_php_73
至于python还是java环境的出题,我暂时还没尝试过,就不班门弄斧了。
如果还想通过更多的环境学习如何出题,可以在这个项目中查看更多的题目。