Notice
Recent Posts
Recent Comments
«   2025/07   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
07-20 03:20
Today
Total
관리 메뉴

해킹공주의 일상

Frida로 CS프로그램 취약점 점검 (Frida 함수 후킹) 본문

모의해킹/CS(frida)

Frida로 CS프로그램 취약점 점검 (Frida 함수 후킹)

h4ckpr1n 2025. 7. 7. 17:08

개인 공부차 수행해본 내용으로, 매끄럽지 못하며 

1. 준비물

파이썬과 프리다를 설치해주자.

이거 버전 아주 중요하니, 아래 버전을 따라주자

 

- python : 3.10

- frida : 15.1.24

- frida-tools : 10.4.1

 

최신 버전의 경우 불안정한 부분도 있고.. 나중에 업데이트!

 

2. 패킷 잡기 및 후킹

1단계. 소켓 통신 여부 확인하기

소켓통신 여부를 확인하는 방법은 여러가지가 있다

 

1) 리소스 모니터 확인

2) Procmon 에서 소켓관련 dll 사용 여부 확인

 

나는 보통 이렇게 두가지를 확인한다.

 

 

프로그램에서 통신이 일어날만한 행위(로그인, 백업 등)을 수행하면 TCP 연결이나 네트워크 활동이 있는 프로세스에 뜬다.

TCP 연결 란에서는 통신 IP도 확인할 수 있는데, 해당 IP로 와이어샤크에서 패킷 확인도 가능하다.

 

Procmon 에서 확인하는 건,, 어떤 dll을 사용하냐에 따라서 사용하는 함수가 다를수도있고, 암호화를 하는지 안하는지를 파악할수도 있기때문에 부가적으로 확인하긴하는데, 안해도 사실 무방.

 

 

2단계. 후킹할 함수 찾기(Frida-trace로 통신하는 함수 잡기)

보통 WS2_32.dll 에 있는 send, recv 함수로 송수신하는데, 요즘은 암호화해서 주고받기도 해서 해당 함수로 안잡히는 경우도 있다. 따라서 아래 python 코드로 어떤 함수로 송수신하는지 잡아주면 트레이싱하기 편하다.

 

아래 코드는 일반적으로 송수신할때 사용하는 dll과 함수를 트레이싱하도록 하는 코드이다. 점검 대상의 PID를 입력하고, 송수신하는 행위를 수행해주자.

// Find 통신 함수 잡기
import frida

pid_input = input("📌 점검할 클라이언트의 PID를 입력하세요: ")
pid = int(pid_input)

session = frida.attach(pid)

print(f"✅ PID {pid}에 attach했습니다.")

js_code = """
const functions = [
    ["WS2_32.dll", "send"],
    ["WS2_32.dll", "recv"],
    ["WS2_32.dll", "WSASend"],
    ["WS2_32.dll", "WSARecv"],
    ["wininet.dll", "HttpSendRequestA"],
    ["wininet.dll", "HttpSendRequestW"],
    ["wininet.dll", "InternetWriteFile"],
    ["wininet.dll", "InternetReadFile"],
    ["winhttp.dll", "WinHttpSendRequest"],
    ["winhttp.dll", "WinHttpWriteData"],
    ["winhttp.dll", "WinHttpReadData"],
    ["schannel.dll", "EncryptMessage"],
    ["schannel.dll", "DecryptMessage"],
    ["crypt32.dll", "CryptEncrypt"],
    ["crypt32.dll", "CryptDecrypt"]
];

functions.forEach(f => {
    try {
        const addr = Module.findExportByName(f[0], f[1]);
        if (addr) {
            Interceptor.attach(addr, {
                onEnter(args) {
                    console.log("[*] " + f[0] + "::" + f[1] + " called");
                }
            });
        }
    } catch (e) {}
});
"""

script = session.create_script(js_code)

def on_message(message, data):
    print(message)

script.on("message", on_message)
script.load()

input("✅ 실행 중입니다. 종료하려면 Enter 키를 누르세요.\n")

 

 

수행 예시는 아래와 같다. 로그인 이나, 백업 행위를 했을때 WSASend 함수가 call 되는 것을 확인했다.

 

 

3단계. 통신 함수 후킹하기

통신할 때 어떤 함수가 쓰이는지 알았으니 이제 후킹해보자.

테스트해보니까 함수마다 후킹해서 출력하는 결과값이 달라서, 함수마다 따로 짜줘야한다.

 

예를들어 send와 WSASend 함수를 비교해보자

int send(
  SOCKET s,
  const char *buf,
  int len,
  int flags
);

int WSASend(
  SOCKET s,
  LPWSABUF lpBuffers,
  DWORD dwBufferCount,
  LPDWORD lpNumberOfBytesSent,
  DWORD dwFlags,
  LPWSAOVERLAPPED lpOverlapped,
  LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);

 

위는 send 와 WSASend 함수의 구조체이다.

 

✅ send → 단일 포인터 + 길이
✅ WSASend → 구조체 배열 (여러 개일 수도 있음)

 

즉, 비유하자면

 

✅ send()는 “편지 봉투 하나”를 그대로 가져다 주는 것

✅ WSASend()는 “봉투가 여러 개 담긴 바구니”를 가져다 주는 것  >  당연히 바구니는 열어봐야 뭐가 몇 개 있는지 알죠!

 

그래서 .. 함수에 맞게 짜주자.

아래는 send 함수와 WSASend 함수의 후킹 (only 출력) 예시다.

// WS2_32.dll - send 함수 후킹 코드

var hook = Module.getExportByName("WS2_32.dll", "send");

Interceptor.attach(hook, {
  onEnter(args) {
    var size = args[2].toInt32();

    console.log("size : " + size);

    var bufferDump = hexdump(args[1], {
      offset: 0,
      length: size,
      ansi: true
    });

    console.log("Buffer Dump:\n" + bufferDump);
  },

  onLeave(result) {
  }
});

 

// WS2_32.dll - WSASend 함수 후킹코드

Interceptor.attach(Module.getExportByName("WS2_32.dll", "WSASend"), {
    onEnter(args) {
        const lpBuffers = args[1];
        const dwBufferCount = args[2].toInt32();

        const WSABUF_SIZE = 4 + Process.pointerSize;

        for (let i = 0; i < dwBufferCount; i++) {
            try {
                const bufStruct = lpBuffers.add(i * WSABUF_SIZE);

                const len = bufStruct.readU32();
                const ptr = bufStruct.add(4).readPointer();

                if (ptr.isNull()) {
                    console.log(`[!] Buffer[${i}] pointer is NULL`);
                    continue;
                }

                const data = Memory.readByteArray(ptr, len);

                if (!data) {
                    console.log(`[!] Buffer[${i}] is empty`);
                    continue;
                }

                console.log(`[*] WSASend Buffer[${i}] (${len} bytes):\n` + hexdump(data, { ansi: true }));
            } catch (e) {
                console.log(`[!] Error processing Buffer[${i}]: ${e.message}`);
            }
        }
    }
});

 

다만, 확인해보니 send() / recv() 이렇게 쌍끼리는 서로 같게 생긴 모양이다. 물론 아닌것도 있을 수 있으니 확인해보도록...

 

어쨌든, 잘 날아왔는지 확인해보기 위해서는 wireshark 에서 패킷을 비교해보면 된다. 리소스 모니터에서 알아낸 ip정보로 와이어샤크에서 검색해서 대조해보면 된다.

 

 

비슷한게 날아오긴했는데, 이 상황에서 할 수 있는건 두가지다.

 

1. 패킷 내 평문으로 데이터가 보이는 경우

: 운이 좋은 경우로, 간단하게 해당 통신 함수를 후킹 해주면 된다.

 

2. 암호화 된 값으로 보이는 경우 

: 위처럼 한번에 보이지 않는 경우, 아래 두가지 방법으로 나뉜다.

 

1) 네트워크 단에서 암호화

: 표준화된 프로토콜 (TLS 1.2, 1.3 등)을 사용하는 경우 암호화는 OS나 라이브러리(SChannel, OpenSSL, GnuTLS 등)가 처리. 네트워크 패킷 스니핑해도 내용은 못 봄

 

🎈 변조해야하는 곳

: 메모리에는 평문으로 저장되어있으므로 전송되기 전 메모리에서 변조 시도

 

2) 애플리케이션에서 암호화

:프로그램이 데이터 생성 직후에 직접 암호화. 암호화 함수를 사용한다. 네트워크로 보내기 전에도 이미 암호화된 데이터.

 

🎈 변조해야하는 곳

: 암호화 함수에 들어가는 인자값 변조 시도.

 

 

암호화 여부 보려면 그냥 문자열 검색해도 된다.

frame contains "찾을 문자열"

 

 

그치만 지금처럼 와이어샤크에서 TLS 통신임을 확인했고, 암호문이 보였을 경우, 메모리를 변조해야한다.

 

 

4단계. 암호화 전 단계 찾기

일단 실습 이므로, 애플리케이션 암호화 가정하고 함수도 찾아보자.

 

4단계-1. 암호화 함수 찾기(Application 암호화 가정)

그렇다면 암호화 함수를 찾아서 암호화 되기전에 패킷 수정하는게 목표다. 따라서 일단 어떤 함수로 암호화 하는지 찾아보자.

 

실제로 많이 사용되는 애들 위주로 검색해보자.

 

📍 테스트 해볼 모듈

 

  • schannel.dll::EncryptMessage called → Schannel 기반
  • libssl-1_1.dll::SSL_write called → OpenSSL 기반
  • crypt32.dll::CryptEncrypt called → Crypt32 기반
  • bcrypt.dll::BCryptEncrypt called → BCrypt 기반

 

코드는 아래와 같다.

암호화 모듈 중 로딩 되는 모듈이 출력되며, 해당 모듈이 수행되면 수행된다고 뜬다.

이중에서 로딩 되는 모듈이 있다면 해당 모듈을 사용하고 있다고 의심해볼 수 있겠다.

 

// FIND 암호화 모듈확인 코드

const patterns = [
    ["schannel.dll", "EncryptMessage"],
    ["schannel.dll", "DecryptMessage"],
    ["crypt32.dll", "CryptEncrypt"],
    ["crypt32.dll", "CryptDecrypt"],
    ["bcrypt.dll", "BCryptEncrypt"],
    ["bcrypt.dll", "BCryptDecrypt"],
    ["ncrypt.dll", "NCryptEncrypt"],
    ["ncrypt.dll", "NCryptDecrypt"],
    ["libssl-1_1.dll", "SSL_write"],
    ["libssl-1_1.dll", "SSL_read"],
    ["WS2_32.dll", "WSASend"]
];

patterns.forEach(([dll, func]) => {
    try {
        const addr = Module.findExportByName(dll, func);
        if (!addr) {
            console.log(`[!] ${dll}::${func} not found`);
            return;
        }

        console.log(`[+] Hooking ${dll}::${func}`);

        Interceptor.attach(addr, {
            onEnter(args) {
                console.log(`✅ ${dll}::${func} 수행됨!`);
            }
        });
    } catch (e) {
        console.log(`[!] Error hooking ${dll}::${func}: ${e.message}`);
    }
});

 

 

결과는 아래와 같다.

실제로 로드가 된 BCryptEncrypt 가 잡혔다. 해당 모듈을 사용하고있을 가능성이 있다.

하지만 실제로 백업이나 로그인같은 통신 행위를 했을때 암호화 함수가 안뜬다. 

 

혹시나 싶어 해당 함수를 trace 해보니 WSASend()할때 암호화 함수를 사용하고있지 않다... 

frida-trace -p [pid] -i "BC" -i "WSASend"

 

위에서 유추한 대로, 별도의 함수로 암호화 하는 것을 확인하지 못했다. 

그렇다면 다음 단계로 암호화 하기 전 데이터를 변조해보자. 

 

 

암호화 안하는데 BCryptEncrypt 모듈이 올라와있는 이유

 

1️⃣  운영체제가 기본적으로 로드
 -  bcrypt.dll은 윈도우의 Crypto Next Generation (CNG) 라이브러리입니다.
 -  SChannel도 내부적으로 bcrypt.dll을 사용합니다.
 -  즉, 애플리케이션이 SChannel/WinHTTP/WinINet을 쓰면 OS는 알아서 bcrypt.dll을 로드합니다.

하지만, OS가 로드해놨다고 해서 애플리케이션이 BCryptEncrypt()를 직접 호출한다는 뜻은 아닙니다.

2️⃣ 암호화는 커널 모드에서 처리
-  TLS 세션을 설정한 후 데이터 암호화는 커널 모드 드라이버(tcpip.sys, schannel.sys)에서 처리되며,
-  그 단계에서는 유저 모드에서 BCryptEncrypt()를 호출할 필요가 없습니다.

 3️⃣ 혹시 모를 용도 때문에 미리 로드
 -  어떤 프로그램은 로그인이나 토큰 처리 시에 대칭키 암호화를 할 가능성도 있습니다.

 

 

요렇게 세가지로 나뉠 수 있는데,, 아무튼 사용하지 않는데도 올라와있을 수 있으니 참고하자.

 

4단계-2. 메모리 버퍼 평문 변조(네트워크 암호화 가정)

여러가지 방면으로 확인 해본 결과, Application 단에서는 암호화 하지 않는 것 같다. 따라서... 위에서 언급한대로 평문 조립단계에서 출력해보고 변조를 시도해봐야한다. 

 

어떤 경우에는 TLS 암호화 함수를 써서 (SSL_Write 등) 암호화 하기도 하는데, 해당 함수들도 다 해본 결과는 아래와 같다

 

✅ TLS 1.3이 유저 모드 함수 호출 없이 처리될 수도 있다
✅ 이 경우 평문은 애플리케이션 로직에서 조립되고 TLS 스택으로 내려가기 때문에, 애플리케이션 계층을 후킹해야 함

  

암호화를 수행하기 전에 먼저 Write를 수행할 테니 윗단을 확인해보았다.

 

📍 애플리케이션 전송 순서 게시글 참고 > > https://7-3-7.tistory.com/361

 

애플리케이션의 전송 순서

해킹공주의 일상 애플리케이션의 전송 순서 본문 모의해킹 애플리케이션의 전송 순서 7.3.7 2025. 7. 7. 15:55

7-3-7.tistory.com

 

아래 코드에는 write 할때 주로 사용하는 함수 모음이 있고, 호출되거나 모듈이 로딩 되어있으면 출력하도록 되어있다.

// FIND Write 모듈

const functions = [
    ["msvcrt.dll", "sprintf"],
    ["msvcrt.dll", "snprintf"],
    ["msvcrt.dll", "memcpy"],
    ["msvcrt.dll", "memmove"],
    ["kernel32.dll", "WriteFile"],
    ["wininet.dll", "InternetWriteFile"],
    ["winhttp.dll", "WinHttpWriteData"],
    ["bcrypt.dll", "BCryptEncrypt"],
    ["secur32.dll", "EncryptMessage"],
    ["libssl-1_1.dll", "SSL_write"]
];

functions.forEach(([dll, func]) => {
    try {
        const addr = Module.findExportByName(dll, func);
        if (!addr) {
            console.log(`[!] ${dll}::${func} not found`);
            return;
        }

        console.log(`[+] Hooking ${dll}::${func}`);

        Interceptor.attach(addr, {
            onEnter(args) {
                console.log(`✅ ${dll}::${func} called`);
            }
        });
    } catch (e) {
        console.log(`[!] Error hooking ${dll}::${func}: ${e.message}`);
    }
});

 

 

확인 결과, sprintf, memcpy,memove,Writefile... 등등 사용되고 있음을 확인할 수 있었다. 

 

 

하지만 실제로 네트워크 통신행위를 수행해본 결과,  memove, memcpy, writefile만 실행.

 

 

frida-trace를 수행하고 송수신 행동을 해봐도.. 통신할때 사용하는 함수로 유추되는 WinHttpSendRequest는 실행되지않음을 확인

frida-trace -p [pid] -i "WSASend" -i "WinHttpSendRequest"

 

 

그냥 의심되는 함수에서 출력되는 내용을 전부 확인해야겠다 싶어서 아래와 같은 코드를 짰다.

아래 코드는  memove, memcpy, writefile 함수에서 내가 "찾는 문자열" 이 존재할 때, 모듈이름과 데이터를 ASCII 코드로 출력하는 코드이다.

// Write 관련 평문 버퍼 기록 함수에서 내가 원하는값 검색하는 코드
const targetNames = ["writefile", "memcpy", "memmove"];
const callCounts = {};  // 함수별 호출 카운트

function now() {
    const d = new Date();
    return d.toISOString();
}

["kernel32.dll", "msvcrt.dll"].forEach(mod => {
    try {
        const exports = Module.findBaseAddress(mod) ? Module.enumerateExports(mod) : [];

        exports.forEach(exp => {
            const lname = exp.name.toLowerCase();

            if (targetNames.includes(lname)) {
                console.log(`[+] Hooking ${mod}::${exp.name}`);

                Interceptor.attach(exp.address, {
                    onEnter(args) {
                        const funcName = `${mod}::${exp.name}`;
                        let ptr, len;

                        // 호출 카운트 증가
                        if (!callCounts[funcName]) {
                            callCounts[funcName] = 1;
                        } else {
                            callCounts[funcName]++;
                        }

                        try {
                            if (lname === "writefile") {
                                ptr = args[1];
                                len = args[2].toInt32();
                            } else if (lname === "memcpy" || lname === "memmove") {
                                ptr = args[0];
                                len = args[2].toInt32();
                            } else {
                                return;
                            }

                            if (!ptr || len <= 0) return;

                            const buf = Memory.readByteArray(ptr, len);
                            const bytes = new Uint8Array(buf);

                            // ASCII printable로 변환
                            const ascii = Array.from(bytes)
                                .map(b => (b >= 0x20 && b <= 0x7e) ? String.fromCharCode(b) : '.')
                                .join('');

                            const pos = ascii.indexOf("978146"); // ★★★★ 찾는 문자열 입력

                            if (pos !== -1) {
                                const contextStart = Math.max(0, pos - 50);
                                const contextEnd = Math.min(ascii.length, pos + 150);

                                const context = ascii.slice(contextStart, contextEnd);

                                console.log(`✅ [${now()}] ${funcName} (#${callCounts[funcName]}) called with '978146'`);
                                console.log(`    Buffer Length: ${len}`);
                                console.log(`    Found at offset: ${pos}`);
                                console.log(`    Context: ${context}`);
                            }

                        } catch (e) {
                            console.log(`[!] ${funcName} error: ${e.message}`);
                        }
                    }
                });
            }
        });
    } catch (e) {
        console.log(`[!] Error processing ${mod}: ${e.message}`);
    }
});

 

 

수행 결과, 로딩된 모듈과 내가 찾던 문자열(이메일 주소)를 드디어 찾아낼 수 있었다.

 

다만, 해당 값이 실제로 넘어가는 값일수도 있고, 아니면 단순 디스크에 저장되는 값일수도 있다.  따라서 일단 변조해보고 반응을 확인해보자.

 

Write 관련 함수에서 변조시도할 문자열을 잡아서 변조를 해보았다.

// Write 관련 평문 버퍼 탐지 및 변조

const targetNames = ["writefile", "memcpy", "memmove"];
const callCounts = {};  // 함수별 호출 카운트

function now() {
    const d = new Date();
    return d.toISOString();
}

["kernel32.dll", "msvcrt.dll"].forEach(mod => {
    try {
        const exports = Module.findBaseAddress(mod) ? Module.enumerateExports(mod) : [];

        exports.forEach(exp => {
            const lname = exp.name.toLowerCase();

            if (targetNames.includes(lname)) {
                console.log(`[+] Hooking ${mod}::${exp.name}`);

                Interceptor.attach(exp.address, {
                    onEnter(args) {
                        const funcName = `${mod}::${exp.name}`;
                        let ptr, len;

                        // 호출 카운트 증가
                        callCounts[funcName] = (callCounts[funcName] || 0) + 1;

                        try {
                            if (lname === "writefile") {
                                ptr = args[1];
                                len = args[2].toInt32();
                            } else if (lname === "memcpy" || lname === "memmove") {
                                ptr = args[0];
                                len = args[2].toInt32();
                            } else {
                                return;
                            }

                            if (!ptr || len <= 0) return;

                            const buf = Memory.readByteArray(ptr, len);
                            const bytes = new Uint8Array(buf);

                            const ascii = Array.from(bytes)
                                .map(b => (b >= 0x20 && b <= 0x7e) ? String.fromCharCode(b) : '.')
                                .join('');

							const findStr = "978146"
                            const pos = ascii.indexOf(findStr);

                            if (pos !== -1) {
                                const contextStart = Math.max(0, pos - 50);
                                const contextEnd = Math.min(ascii.length, pos + 150);
                                const contextBefore = ascii.slice(contextStart, contextEnd);

                                console.log(`✅ [${now()}] ${funcName} (#${callCounts[funcName]}) detected '${findStr}'`);
                                console.log(`    Buffer Length: ${len}`);
                                console.log(`    Found at offset: ${pos}`);
                                console.log(`    Context (before): ${contextBefore}`);
								
								/* 변조 시, 사용하는 부분 */
								/*
                                // 🔷 변조 시작
                                const patchStr = "12345";
                                for (let i = 0; i < patchStr.length; i++) {
                                    Memory.writeU8(ptr.add(pos + i), patchStr.charCodeAt(i));
                                }
                                console.log(`    🔷 Patched '${findStr}' → '${patchStr}'`);

                                // 🔷 변조 후 상태 출력
                                const afterBuf = Memory.readByteArray(ptr, len);
                                const afterBytes = new Uint8Array(afterBuf);
                                const afterAscii = Array.from(afterBytes)
                                    .map(b => (b >= 0x20 && b <= 0x7e) ? String.fromCharCode(b) : '.')
                                    .join('');
                                const contextAfter = afterAscii.slice(contextStart, contextEnd);

                                console.log(`    Context (after): ${contextAfter}`);
								*/
                            }

                        } catch (e) {
                            console.log(`[!] ${funcName} error: ${e.message}`);
                        }
                    }
                });
            }
        });
    } catch (e) {
        console.log(`[!] Error processing ${mod}: ${e.message}`);
    }
});

 

 

아래와 같이 프로그램에 자동으로 저장되는 계정 정보 데이터가 변조됨을 확인하였다.

 

현재 테스트 프로그램의 경우에는 기능이 별로 없고, 넘어가는 데이터도 별로 없어서 로그인만 수행해보았으나 다른 cs 프로그램 점검할때 도움이 되지 않을가 싶다..!

 

Comments