HyperSonic CTF Writeup CTF/Wargame

HyperSonic CTF 2026 ele Writeup

HyperSonic CTF 2026 ele 문제 풀이HyperSonic CTF 2026 ele writeup

Contents

개요

ele는 Electron 기반 문서 뷰어를 분석하는 reversing 문제입니다. 프로그램은 라이선스 키를 입력받고, 검증에 성공하면 암호화된 PDF를 복호화해 화면에 표시합니다.

문제 조건은 다음과 같습니다.

  • 카테고리: reversing
  • 목표: 올바른 라이선스 키와 PDF 복호화 키를 찾아 문서 안의 validation key를 확인합니다.

핵심은 renderer에서 시작되는 복호화 요청입니다. UI는 라이선스 키만 입력받지만, 실제 검증과 복호화는 Electron main process에서 처리됩니다.

문제 분석

글에서 사용할 대상은 다음과 같습니다.

  • licenseKey: 사용자가 UI에 입력하는 라이선스 키입니다.
  • pdfKey: AES 복호화에 쓰이는 32바이트 키입니다.
  • dat: 암호화된 PDF 리소스 전체입니다.
  • iv: dat의 앞 16바이트입니다.
  • ciphertext: dat에서 iv를 제외한 나머지 암호문입니다.
  • _getLicenseKey(): 올바른 라이선스 키를 만드는 내부 함수입니다.
  • _getPdfKey(): PDF 복호화 키를 만드는 내부 함수입니다.

제공된 프로그램은 Electron 앱입니다. 패키지 내부에는 renderer, preload, main process 코드가 들어 있고, PDF는 별도 리소스로 암호화되어 저장되어 있습니다.

renderer 쪽 흐름은 단순합니다. 입력값을 읽은 뒤 preload에서 노출한 API를 호출합니다.

javascript
var licenseKey = input.value.trim();

window.secureApi.decrypt(licenseKey).then(function (b64) {
  if (!b64) {
    setMsg("Invalid license key. Validation failed.", "msg-error");
    return;
  }

  pdfEmbed.src = "data:application/pdf;base64," + b64;
});

preload 코드는 이 호출을 decrypt-doc IPC로 넘깁니다.

javascript
contextBridge.exposeInMainWorld("secureApi", {
  decrypt: function (licenseKey) {
    return ipcRenderer.invoke("decrypt-doc", licenseKey);
  }
});

따라서 분석해야 할 곳은 decrypt-doc handler입니다. main process 코드는 문자열 난독화가 걸려 있었지만, 흐름을 정리하면 다음과 같습니다.

javascript
ipcMain.handle("decrypt-doc", async function (_, userInput) {
  license = await _getLicenseKey();

  if (!userInput || userInput.toUpperCase() !== license) {
    return null;
  }

  dat = fs.readFileSync("encrypted.dat");
  iv = dat.slice(0, 16);
  ciphertext = dat.slice(16);

  key = await _getPdfKey();

  dec = crypto.createDecipheriv("aes-256-cbc", key, iv);
  pdf = Buffer.concat([dec.update(ciphertext), dec.final()]);

  return pdf.toString("base64");
});

라이선스 키 검증은 _getLicenseKey()의 결과와 단순 비교이고, PDF 복호화는 AES-256-CBC로 이루어집니다. 따라서 내부 함수 두 개의 반환값만 얻으면 UI를 거치지 않고도 PDF를 복호화할 수 있습니다.

핵심 아이디어

난독화된 코드를 사람이 읽기 쉬운 형태로 전부 복원할 필요는 없습니다. _getLicenseKey()와 _getPdfKey()는 main process 코드 안에 이미 존재하고, 실행 시점에 필요한 값을 직접 계산합니다.

다만 main process 코드를 그대로 Node에서 실행하면 Electron 객체와 실행 환경 검사에 걸립니다. 이 부분은 다음처럼 처리합니다.

text
1. electron 모듈을 mock한다.
2. process.platform과 process.versions.electron을 Electron 환경처럼 맞춘다.
3. app.quit(), process.exit()처럼 분석에 방해되는 함수는 no-op으로 둔다.
4. main process 코드를 VM 안에서 실행한다.
5. 전역 객체에 _getLicenseKey, _getPdfKey를 노출해 호출한다.

이렇게 하면 난독화된 문자열 테이블과 WASM 초기화까지 원래 코드가 직접 처리합니다. 모든 상수를 직접 복원할 필요 없이 프로그램의 키 생성 루틴만 재사용하면 됩니다.

풀이 과정

Step 1. UI에서 IPC 흐름 확인

renderer 코드를 보면 사용자가 입력한 라이선스 키가 window.secureApi.decrypt()로 전달됩니다. 이 함수는 preload에서 ipcRenderer.invoke("decrypt-doc", licenseKey)로 연결됩니다.

따라서 분석 대상은 UI가 아니라 main process의 decrypt-doc handler입니다. renderer는 복호화 결과를 base64 PDF로 받아 화면에 붙일 뿐입니다.

Step 2. 복호화 handler 정리

decrypt-doc handler는 사용자 입력과 _getLicenseKey() 결과를 비교합니다. 비교에 실패하면 null을 반환하고, 성공하면 암호화된 리소스를 읽어 복호화합니다.

복호화 데이터 구조는 다음과 같습니다.

text
dat[0:16]   = iv
dat[16:]    = ciphertext
algorithm   = aes-256-cbc
key         = _getPdfKey()

따라서 필요한 값은 _getLicenseKey()의 문자열 결과와 _getPdfKey()의 32바이트 결과입니다.

Step 3. 내부 함수 호출 환경 만들기

main process 코드는 Electron 앱으로 실행되는 상황을 가정합니다. 그래서 Node VM에서 실행하기 전에 최소한의 mock 객체를 준비했습니다.

javascript
function makeElectronMock() {
  return {
    app: {
      quit() {},
      on() {},
      whenReady() {
        return { then() {} };
      }
    },
    BrowserWindow: class {
      constructor() {
        this.webContents = { on() {}, closeDevTools() {} };
      }
      loadFile() {}
      setMenu() {}
      on() {}
    },
    ipcMain: {
      handle(channel, handler) {
        this.channel = channel;
        this.handler = handler;
      }
    }
  };
}

또한 process.platform은 win32로, process.versions.electron은 존재하는 값으로 맞췄습니다. main process 코드 끝에는 창을 여는 코드가 있지만, 위 mock으로 충분히 지나갈 수 있습니다.

코드를 VM에서 실행할 때는 마지막에 내부 함수를 전역 객체로 노출했습니다.

javascript
const source = mainSource + `
globalThis.__drm = { _getLicenseKey, _getPdfKey };
`;

vm.runInContext(source, context);

const licenseKey = await context.__drm._getLicenseKey();
const pdfKey = await context.__drm._getPdfKey();

실행 결과는 다음과 같습니다.

text
License Key : 1A62-5880-C435-52BA
PDF Key     : a79cc71dfa2bdd5a6bd746e484a47351014bb420c3cf0f18f96f7a23db2debc6

Step 4. PDF 복호화

키를 얻은 뒤에는 handler가 하던 작업을 그대로 재현합니다. 암호화된 리소스의 앞 16바이트를 iv로, 나머지를 ciphertext로 씁니다.

javascript
const dat = fs.readFileSync(encryptedResource);
const iv = dat.subarray(0, 16);
const ciphertext = dat.subarray(16);

const decipher = crypto.createDecipheriv("aes-256-cbc", pdfKey, iv);
const pdf = Buffer.concat([
  decipher.update(ciphertext),
  decipher.final()
]);

복호화 결과는 정상 PDF였습니다. PDF 텍스트를 추출하면 validation key가 문서 안에 들어 있습니다.

text
VALIDATION KEY

    hs{1+1=flag}

Exploit / Solver

solver는 내부 키 생성 함수를 직접 호출한 뒤 같은 AES 설정으로 PDF를 복호화합니다. 아래 코드는 필요한 부분만 줄인 형태입니다.

javascript
const crypto = require("crypto");
const fs = require("fs");
const vm = require("vm");

function makeContext() {
  const electron = makeElectronMock();

  return {
    Buffer,
    WebAssembly,
    console,
    process: {
      env: process.env,
      exit() {},
      platform: "win32",
      resourcesPath: process.cwd(),
      versions: { ...process.versions, electron: "32.0.0" }
    },
    require(name) {
      if (name === "electron") return electron;
      return require(name);
    }
  };
}

async function solve(mainSource, encryptedResource) {
  const context = vm.createContext(makeContext());
  vm.runInContext(
    mainSource + "\nglobalThis.__drm = { _getLicenseKey, _getPdfKey };",
    context
  );

  const licenseKey = await context.__drm._getLicenseKey();
  const pdfKey = await context.__drm._getPdfKey();

  const dat = fs.readFileSync(encryptedResource);
  const iv = dat.subarray(0, 16);
  const ciphertext = dat.subarray(16);

  const decipher = crypto.createDecipheriv("aes-256-cbc", pdfKey, iv);
  const pdf = Buffer.concat([decipher.update(ciphertext), decipher.final()]);

  return {
    licenseKey,
    pdfKey: pdfKey.toString("hex"),
    pdf
  };
}

이 코드는 난독화된 main process 로직을 다시 구현하지 않고, 원래 코드의 _getLicenseKey()와 _getPdfKey()를 호출해 값을 얻은 뒤 복호화 흐름만 그대로 재현합니다.

결과

재현 결과는 다음과 같습니다.

text
License Key : 1A62-5880-C435-52BA
PDF Key     : a79cc71dfa2bdd5a6bd746e484a47351014bb420c3cf0f18f96f7a23db2debc6
Crypto      : AES-256-CBC

복호화된 PDF에서 validation key를 확인했습니다.

text
hs{1+1=flag}

Overview

ele is a reversing challenge that involves analyzing an Electron-based document viewer. The program takes a license key as input and, on successful validation, decrypts an encrypted PDF and displays it on screen.

The challenge setup:

  • Category: reversing
  • Goal: find the correct license key and PDF decryption key, and check the validation key inside the document.

The key is the decryption request that originates in the renderer. The UI only takes a license key, but the actual validation and decryption happen in the Electron main process.

Challenge analysis

The objects referred to below:

  • licenseKey: the license key the user enters in the UI.
  • pdfKey: the 32-byte key used for AES decryption.
  • dat: the entire encrypted PDF resource.
  • iv: the first 16 bytes of dat.
  • ciphertext: the remaining ciphertext in dat after the iv.
  • _getLicenseKey(): the internal function that produces the correct license key.
  • _getPdfKey(): the internal function that produces the PDF decryption key.

The provided program is an Electron app. Inside the package are the renderer, preload, and main process code, and the PDF is stored encrypted as a separate resource.

The renderer-side flow is simple: it reads the input and calls the API exposed by preload.

javascript
var licenseKey = input.value.trim();

window.secureApi.decrypt(licenseKey).then(function (b64) {
  if (!b64) {
    setMsg("Invalid license key. Validation failed.", "msg-error");
    return;
  }

  pdfEmbed.src = "data:application/pdf;base64," + b64;
});

The preload code passes this call on as the decrypt-doc IPC.

javascript
contextBridge.exposeInMainWorld("secureApi", {
  decrypt: function (licenseKey) {
    return ipcRenderer.invoke("decrypt-doc", licenseKey);
  }
});

So the place to analyze is the decrypt-doc handler. The main process code had string obfuscation, but the flow boils down to the following.

javascript
ipcMain.handle("decrypt-doc", async function (_, userInput) {
  license = await _getLicenseKey();

  if (!userInput || userInput.toUpperCase() !== license) {
    return null;
  }

  dat = fs.readFileSync("encrypted.dat");
  iv = dat.slice(0, 16);
  ciphertext = dat.slice(16);

  key = await _getPdfKey();

  dec = crypto.createDecipheriv("aes-256-cbc", key, iv);
  pdf = Buffer.concat([dec.update(ciphertext), dec.final()]);

  return pdf.toString("base64");
});

License key validation is a simple comparison against the result of _getLicenseKey(), and PDF decryption uses AES-256-CBC. So if we obtain the return values of the two internal functions, we can decrypt the PDF without going through the UI.

Key idea

We do not need to fully restore the obfuscated code into a human-readable form. _getLicenseKey() and _getPdfKey() already exist inside the main process code and compute the values they need at runtime.

But running the main process code directly in Node trips over Electron objects and runtime environment checks. This part is handled as follows.

text
1. Mock the electron module.
2. Set process.platform and process.versions.electron to look like an Electron environment.
3. Leave functions that get in the way of analysis, like app.quit() and process.exit(), as no-ops.
4. Run the main process code inside a VM.
5. Expose _getLicenseKey and _getPdfKey on the global object and call them.

This lets the original code handle the obfuscated string table and the WASM initialization itself. There is no need to recover every constant by hand; reusing the program's key-generation routines is enough.

Solution

Step 1. Confirm the IPC flow from the UI

In the renderer code, the license key the user enters is passed to window.secureApi.decrypt(). This function is wired in preload to ipcRenderer.invoke("decrypt-doc", licenseKey).

So the analysis target is the main process's decrypt-doc handler, not the UI. The renderer only takes the decryption result as a base64 PDF and attaches it to the screen.

Step 2. Work out the decryption handler

The decrypt-doc handler compares the user input with the result of _getLicenseKey(). On a failed comparison it returns null, and on success it reads the encrypted resource and decrypts it.

The decryption data structure:

text
dat[0:16]   = iv
dat[16:]    = ciphertext
algorithm   = aes-256-cbc
key         = _getPdfKey()

So the values needed are the string result of _getLicenseKey() and the 32-byte result of _getPdfKey().

Step 3. Set up an environment to call the internal functions

The main process code assumes it is running as an Electron app. So before running it in a Node VM, I prepared a minimal set of mock objects.

javascript
function makeElectronMock() {
  return {
    app: {
      quit() {},
      on() {},
      whenReady() {
        return { then() {} };
      }
    },
    BrowserWindow: class {
      constructor() {
        this.webContents = { on() {}, closeDevTools() {} };
      }
      loadFile() {}
      setMenu() {}
      on() {}
    },
    ipcMain: {
      handle(channel, handler) {
        this.channel = channel;
        this.handler = handler;
      }
    }
  };
}

I also set process.platform to win32 and process.versions.electron to an existing value. The main process code ends with code that opens a window, but the mock above is enough to get past it.

When running the code in the VM, I exposed the internal functions as global objects at the end.

javascript
const source = mainSource + `
globalThis.__drm = { _getLicenseKey, _getPdfKey };
`;

vm.runInContext(source, context);

const licenseKey = await context.__drm._getLicenseKey();
const pdfKey = await context.__drm._getPdfKey();

The execution result is as follows.

text
License Key : 1A62-5880-C435-52BA
PDF Key     : a79cc71dfa2bdd5a6bd746e484a47351014bb420c3cf0f18f96f7a23db2debc6

Step 4. Decrypt the PDF

Once the keys are obtained, reproduce what the handler did: use the first 16 bytes of the encrypted resource as the iv and the rest as the ciphertext.

javascript
const dat = fs.readFileSync(encryptedResource);
const iv = dat.subarray(0, 16);
const ciphertext = dat.subarray(16);

const decipher = crypto.createDecipheriv("aes-256-cbc", pdfKey, iv);
const pdf = Buffer.concat([
  decipher.update(ciphertext),
  decipher.final()
]);

The decryption result was a valid PDF, and extracting the PDF text reveals the validation key inside the document.

text
VALIDATION KEY

    hs{1+1=flag}

Exploit / Solver

The core flow of the solver is to call the internal key-generation functions directly, then decrypt the PDF with the same AES settings. The code below is trimmed down to just the necessary parts.

javascript
const crypto = require("crypto");
const fs = require("fs");
const vm = require("vm");

function makeContext() {
  const electron = makeElectronMock();

  return {
    Buffer,
    WebAssembly,
    console,
    process: {
      env: process.env,
      exit() {},
      platform: "win32",
      resourcesPath: process.cwd(),
      versions: { ...process.versions, electron: "32.0.0" }
    },
    require(name) {
      if (name === "electron") return electron;
      return require(name);
    }
  };
}

async function solve(mainSource, encryptedResource) {
  const context = vm.createContext(makeContext());
  vm.runInContext(
    mainSource + "\nglobalThis.__drm = { _getLicenseKey, _getPdfKey };",
    context
  );

  const licenseKey = await context.__drm._getLicenseKey();
  const pdfKey = await context.__drm._getPdfKey();

  const dat = fs.readFileSync(encryptedResource);
  const iv = dat.subarray(0, 16);
  const ciphertext = dat.subarray(16);

  const decipher = crypto.createDecipheriv("aes-256-cbc", pdfKey, iv);
  const pdf = Buffer.concat([decipher.update(ciphertext), decipher.final()]);

  return {
    licenseKey,
    pdfKey: pdfKey.toString("hex"),
    pdf
  };
}

This code does not reimplement the obfuscated main process logic. It calls _getLicenseKey() and _getPdfKey() from the original code to get the values, then reproduces only the decryption flow we worked out.

Result

The reproduction result:

text
License Key : 1A62-5880-C435-52BA
PDF Key     : a79cc71dfa2bdd5a6bd746e484a47351014bb420c3cf0f18f96f7a23db2debc6
Crypto      : AES-256-CBC

I confirmed the validation key in the decrypted PDF.

text
hs{1+1=flag}
Esc

Search titles, content and code.