From 5d9d99684a8dab14b36628d542203c073f5bcac3 Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Sat, 2 Aug 2025 17:06:11 +0000 Subject: [PATCH] fear: add decryptor --- package.json | 10 +-- sources/src/decryptor.ts | 146 +++++++++++++++++++++++++++++++++++++++ sources/src/index.ts | 28 ++++++++ 3 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 sources/src/decryptor.ts diff --git a/package.json b/package.json index 1f363f9..4b30f12 100644 --- a/package.json +++ b/package.json @@ -37,12 +37,12 @@ ], "license": "MIT", "dependencies": { - "@types/node": "^22.15.2" + "@types/node": "^22.17.0" }, "devDependencies": { - "esbuild": "^0.25.3", - "eslint": "^9.25.1", - "typescript": "^5.8.3", - "typescript-eslint": "^8.31.0" + "esbuild": "^0.25.8", + "eslint": "^9.32.0", + "typescript": "^5.9.2", + "typescript-eslint": "^8.38.0" } } diff --git a/sources/src/decryptor.ts b/sources/src/decryptor.ts new file mode 100644 index 0000000..55b0a2f --- /dev/null +++ b/sources/src/decryptor.ts @@ -0,0 +1,146 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// Generated by ChatGPT o4-mini-high and some manual edits + +// 1. 커스텀 Base64 디코더 (알파벳은 원본 코드의 것) +const CUSTOM_B64_ALPHABET = 'LoCpFhMUgtDnQXE6kBz_y-7Hb8SmIjaJO2l30WixfPKV9Auv1Rq4ZY5wdseTNrGc' +const B64_REVERSE = (() => { + const m = Object.create(null) + for (let i = 0; i < CUSTOM_B64_ALPHABET.length; i++) { + m[CUSTOM_B64_ALPHABET[i]] = i + } + return m +})() + +/** + * Base64-like 문자열을 바이트로 디코딩. + * 패딩 문자가 없고 길이에 따라 끝이 잘린 상태를 처리. + */ +function decodeCustomBase64(str) { + const bytes = [] + let i = 0 + while (i + 4 <= str.length) { + const v0 = B64_REVERSE[str[i++]] + const v1 = B64_REVERSE[str[i++]] + const v2 = B64_REVERSE[str[i++]] + const v3 = B64_REVERSE[str[i++]] + bytes.push((v0 << 2) | (v1 >> 4)) + bytes.push(((v1 & 0xf) << 4) | (v2 >> 2)) + bytes.push(((v2 & 0x3) << 6) | v3) + } + // 남은 2~3 글자 처리 (패딩이 생략된 경우) + const rem = str.length - i + if (rem === 2) { + const v0 = B64_REVERSE[str[i++]] + const v1 = B64_REVERSE[str[i++]] + bytes.push((v0 << 2) | (v1 >> 4)) + } else if (rem === 3) { + const v0 = B64_REVERSE[str[i++]] + const v1 = B64_REVERSE[str[i++]] + const v2 = B64_REVERSE[str[i++]] + bytes.push((v0 << 2) | (v1 >> 4)) + bytes.push(((v1 & 0xf) << 4) | (v2 >> 2)) + } + return new Uint8Array(bytes) +} + +// 2. RC4 구현 (원본과 동일한 KSA/PRGA) +function rc4(keyBytes, dataBytes) { + const S = new Uint8Array(256) + for (let i = 0; i < 256; i++) S[i] = i + let j = 0 + // KSA: keyBytes can be any length; original used 32-byte array + for (let i = 0; i < 256; i++) { + j = (j + S[i] + keyBytes[i % keyBytes.length]) & 0xff; + [S[i], S[j]] = [S[j], S[i]] + } + // PRGA + let i = 0 + j = 0 + const out = new Uint8Array(dataBytes.length) + for (let k = 0; k < dataBytes.length; k++) { + i = (i + 1) & 0xff + j = (j + S[i]) & 0xff; + [S[i], S[j]] = [S[j], S[i]] + const K = S[(S[i] + S[j]) & 0xff] + out[k] = dataBytes[k] ^ K + } + return out +} + +// --- 장소표시자: key/nonce로부터 실제 값을 도출하는 함수들 --- + +/** + * 예시: key + nonce로부터 헤더 마스크(원래 17바이트)를 생성. + * 실제 원본 구현을 모르면 이 부분을 대체해야 함. + * 여기서는 단순히 고정값 예시를 사용하는 형태. + */ +function deriveHeaderMask() { + // 만약 실제가 MD5(key + nonce) 기반이라면 여기서 해시를 계산하고 + // 앞 17 바이트를 사용. 이 예에서는 고정 "2efe3d23aec798e47" ascii. + const fixedAscii = '2efe3d23aec798e47' + const arr = new Uint8Array(fixedAscii.length) + for (let i = 0; i < fixedAscii.length; i++) { + arr[i] = fixedAscii.charCodeAt(i) + } + return arr // length 17 +} + +/** + * 예시: key + nonce로부터 RC4 키(32바이트)를 만드는 함수. + * 실제 구현에 따라 대체할 것. (예: HMAC-SHA256(key, nonce)로 확장 등) + * 여기선 원본 코드에 쓰인 고정 32바이트를 그대로 사용. + */ +function deriveRc4Key() { + // 원본 코드의 고정 배열: + return new Uint8Array([ + 37, 67, 13, 50, 127, 0, 34, 98, 208, 44, 155, 179, 137, 222, 69, 119, + 229, 72, 43, 65, 30, 49, 79, 111, 240, 221, 12, 50, 44, 30, 220, 245 + ]) +} + +// --- 최종 복원 함수 --- + +/** + * "/i/..." 토큰으로부터 원래 경로(물음표 이전)를 복원. + * @param {string} token "/i/..." 형태. 뒤에 쿼리가 붙어 있을 수 있음. + * @param {*} key 사용자 제공 key (복원 로직에 맞게 derive 함수 내에서 사용) + * @param {*} nonce 사용자 제공 nonce + * @returns {string} 복원된 원래 경로 + */ +export function decodeIResult(token: string, key: string, nonce: string): string { + // 1. "/i/" 제거, 쿼리 분리 + if (token.startsWith('/i/')) token = token.slice(3) + let queryPart = '' + const qi = token.indexOf('?') + if (qi !== -1) { + queryPart = token.slice(qi) // 그대로 이어붙일 수 있음 + token = token.slice(0, qi) + } + + // 2. 커스텀 base64 디코딩 + const decoded = decodeCustomBase64(token) + if (decoded.length < 1 + 17) { + throw new Error('디코딩 결과가 너무 짧습니다.') + } + + // 3. 길이와 헤더 검증 + const pathLen = decoded[0] // 원래 경로 길이 (mod 256) + const headerBytes = decoded.slice(1, 1 + 17) + const expectedHeader = deriveHeaderMask() + for (let i = 0; i < 17; i++) { + if (headerBytes[i] !== (expectedHeader[i] ^ pathLen)) { + throw new Error('헤더 검증 실패: key/nonce가 잘못됐거나 토큰이 변조됨.') + } + } + + // 4. 암호화된 경로 부분 복원 (RC4) + const cipherPath = decoded.slice(1 + 17, 1 + 17 + pathLen) + const rc4Key = deriveRc4Key() + const plainPathBytes = rc4(rc4Key, cipherPath) + + // 5. UTF-8 디코딩 + const decoder = new TextDecoder() + const path = decoder.decode(plainPathBytes) + + return path + queryPart // 필요 시 원래 쿼리도 복원 +} diff --git a/sources/src/index.ts b/sources/src/index.ts index d6b002c..edb75ed 100644 --- a/sources/src/index.ts +++ b/sources/src/index.ts @@ -1,3 +1,5 @@ +import { decodeIResult } from './decryptor.js' + type unsafeWindow = typeof window // eslint-disable-next-line @typescript-eslint/naming-convention declare const unsafeWindow: unsafeWindow @@ -82,4 +84,30 @@ Win.Function.prototype.bind = new Proxy(Win.Function.prototype.bind, { } return Reflect.apply(Target, ThisArg, Args) } +}) + +Win.fetch = new Proxy(Win.fetch, { + async apply(Target: typeof fetch, ThisArg: typeof Win, Args: Parameters) { + let AwaitResult = Reflect.apply(Target, ThisArg, Args) + let Result = await AwaitResult + if (Result.headers.has('x-namuwiki-key') && Args[0] instanceof Request && Args[0].headers.has('x-namuwiki-nonce') && + decodeIResult(Args[0].url, Result.headers.get('x-namuwiki-key'), Args[0].headers.get('x-namuwiki-nonce'))) { + return new Promise(() => {}) + } + if (Result.headers.has('x-namuwiki-key') && !(Args[0] instanceof Request) && Args[1].headers instanceof Headers && Args[1].headers.has('x-namuwiki-nonce') && + decodeIResult(Args[0] instanceof URL ? Args[0].pathname : Args[0], Result.headers.get('x-namuwiki-key'), Args[1].headers.get('x-namuwiki-nonce'))) { + return new Promise(() => {}) + } + if (Result.headers.has('x-namuwiki-key') && !(Args[0] instanceof Request) && !(Args[1].headers instanceof Headers) && + Array.isArray(Args[1].headers) && Args[1].headers.some(InnerHeader => InnerHeader[0] === 'x-namuwiki-nonce') && + decodeIResult(Args[0] instanceof URL ? Args[0].pathname : Args[0], Result.headers.get('x-namuwiki-key'), Args[1].headers.find(InnerHeader => InnerHeader[0] === 'x-namuwiki-nonce')[1])) { + return new Promise(() => {}) + } + if (Result.headers.has('x-namuwiki-key') && !(Args[0] instanceof Request) && !(Args[1].headers instanceof Headers) && + !Array.isArray(Args[1].headers) && typeof Args[1].headers['x-namuwiki-nonce'] === 'string' && + decodeIResult(Args[0] instanceof URL ? Args[0].pathname : Args[0], Result.headers.get('x-namuwiki-key'), Args[1].headers['x-namuwiki-nonce'])) { + return new Promise(() => {}) + } + return Result + } }) \ No newline at end of file