CS50 Probelm set 4 - 포렌식 이미지

dondonee·2023년 2월 27일
0

CS50

목록 보기
12/12
post-thumbnail
post-custom-banner

포렌식

우리가 컴퓨터에서 무언가를 ‘삭제’했다는 것은 사실 ‘삭제됐다’기 보다는 ‘잊어버렸다’는 의미에 가깝다. 우리가 디지털 카메라로 사진을 잔뜩 찍어놓고 실수로 모든 파일을 삭제해 버렸다고 하자. 메모리 카드가 비어있다고 나오겠지만 그것은 사실이 아니다. 사진을 복구하는 프로그램을 만들어 보자.

JPEG는 BMP에 비해 복잡하지만, 바이트 패턴에 다른 포맷들과 구분되는 시그니처를 가지고 있다. 우선 처음 세 바이트는 아래와 같다.

0xff 0xd8 0xff

그리고 네 번째 바이트는0xe00xe10xe20xe30xe40xe50xe60xe70xe80xe90xea0xeb0xec0xed0xee,  0xef 중에 하나가 된다. 즉 네 번째 바이트의 첫 4비트는 1110이다.

따라서 만약 사진들을 저장했던 것으로 보이는 곳에서 이러한 패턴을 발견했다면 JPEG의 시작 부분일 테지만, 반드시 그렇지 않을 수도 있다.

디지털 카메라는 보통 메모리 카드에 사진 파일들을 연속적으로 저장한다. 각 사진 파일은 직전에 촬영된 사진의 바로 뒤에 저장되므로, 한 JPEG 파일의 시작은 이전 파일의 끝과 경계를 만든다. 디지털 카메라는 보통 블록 크기가 512 바이트인 FAT 파일시스템에 따라 카드를 초기화한다. 즉 카메라는 오직 512 바이트 단위로만 쓰기가 가능한데, 한 사진의 크기가 1MB(1,048,576 bytes)라면 1048576 ÷ 512 = 2048개의 블록을 사용한다. 1 바이트 적은 크기(1,048,575 bytes)의 사진 또한 같은 수의 블록을 사용한다. 이러한 낭비된 공간을 ‘여유 공간’이라고 하는데, 포렌식 수사관들은 의심스러운 데이터의 잔여물을 이러한 여유 공간에서 찾곤 한다.

JPEG 시그니처를 찾아 메모리 카드를 반복해서 읽어들여 사본 파일을 만드는 프로그램을 작성해보자. 시그니처를 발견할 때마다 새 파일을 열고 메모리 카드에서 읽어온 바이트로 채워나가고, 새로운 시그니처를 발견하면 파일을 닫는다. 메모리 카드는 FAT 시스템에 따라 ‘블록 정렬’되어있기 때문에, 한 번에 1 바이트가 아니라 512 바이트씩 버퍼로 읽어올 수 있다. 다시 말해서 한 블록마다 첫 네 바이트만 체크하면 된다.

하나의 JPEG 파일은 연속된 블록에 걸쳐 있을 수 있다. 그렇지 않다면 JPEG 파일은 512 바이트보다 클 수 없을 것이다. 또한 JPEG의 마지막 바이트가 반드시 블록의 끝에 위치하는 것은 아니다. 하지만 이 메모리 카드는 새 것이었고 제조업체에서 0으로 초기화했다고 가정한다. 즉 여유 공간은 0으로 채워져 있을 것이고, 복구한 파일에 이러한 0이 남아있어도 괜찮다.


Recover 과제

아래 예시와 같이 포렌식 이미지 파일에서 JPEG 이미지를 복원하는 프로그램을 작성한다.

$ ./recover
Usage: ./recover image
./recover card.raw

지시사항

  • 프로그램은 명령행 인자로 포렌식 이미지의 파일명 하나만 받는다.
  • 프로그램이 하나의 명령행 인자로 실행되지 않는 경우, 사용자에게 올바른 사용법 메세지를 출력하고 메인 함수에서 1을 반환한다.
  • 프로그램이 포렌식 이미지를 열 수 없는 경우, 사용자에게 안내 메세지를 출력하고 메인 함수에서 1을 반환한다.
  • 프로그램이 malloc을 사용하는 경우 메모리 누수가 없어야 한다.


✍️ 풀이

블록 단위로 접근하기

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <getopt.h>
#include <stdbool.h>

typedef uint8_t BYTE;

#define BLOCK_SIZE 512

bool isJPEG(FILE *file);
void copy_block(FILE *inptr, FILE *outptr);

int main(int argc, char *argv[])
{
    // Check if the correct command-line arguments were provided and print an error message if not.
    if (argc != 2)
    {
        fprintf(stderr, "Usage: recover infile\n");
        return 1;
    }

    char *infile = argv[1];
    FILE *inptr = fopen(infile, "rb");
    if (inptr == NULL)
    {
        fprintf(stderr, "Could not open %s.\n", infile);
        return 1;
    }

    // Get file size and calculate the number of blocks.
    fseek(inptr, 0, SEEK_END);
    const int N = ftell(inptr) / BLOCK_SIZE;

    // Read each 512-byte block of the input file
    int fileCnt = 0;
    char outfile[8];
    FILE *outptr = NULL;

    for (int i = 0; i < N; i++)
    {
        fseek(inptr, BLOCK_SIZE * i, SEEK_SET);

        // If the current block contains the start of a JPEG file, create a new file to write the recovered JPEG data to.
        if (isJPEG(inptr))
        {
            sprintf(outfile, "%03i.jpg", fileCnt++);
            outptr = fopen(outfile, "a");
        }

        if (fileCnt > 0)
        {
            copy_block(inptr, outptr);
        }
    }

    fclose(outptr);
}
  • fseek()으로 포인터를 파일 맨 끝으로 옮긴 뒤 ftell()로 위치를 반환하여 파일의 크기를 알아내고, 그 값을 한 블록의 크기인 512로 나누어 총 블록 수를 계산하여 int형 상수 변수 N에 저장
  • 파일의 첫 블록부터 마지막 블록까지 접근하는 반복문을 생성
    • fseek()으로 해당 블록에 맞는 위치로 포렌식 파일 포인터 이동
    • isJPEG()을 호출하여 블롯의 첫 부분이 JPEG 시그니처와 일치하면 새로운 파일을 생성하고, 복원 파일의 개수를 집계하는 fileCnt 함수를 1 증가
      • sprintf()는 fileCnt의 값을 받아 3자리 정수의 ‘###.jpg’ 파일명을 변수 outfile에 저장한다.
    • copy_block()을 호출하여 해당 블록을 현재 열려있는 파일에 복사
      • fileCnt가 0인 경우 열려있는 파일이 없다는 뜻이므로 copy_block()을 호출하지 않는다
      • 앞의 if 조건문에서 isJPEG()이 true인 경우, 새로운 파일에 해당 블록의 내용이 복사된다. fileCnt가 0보다 크면서 isJPEG()이 false라면 해당 블록의 내용이 이전에 열린 파일에 뒤이어서 복사된다.
  • 블록에 접근하는 반복문이 끝나면 파일 스트림을 닫는다.

JPEG 시그니처 확인

bool isJPEG(FILE *file)
{
    BYTE start_bytes[4];
    fread(&start_bytes, 1, 4, file);
    fseek(file, -4, SEEK_CUR);

    if (start_bytes[0] == 0xff && start_bytes[1] == 0xd8 && start_bytes[2] == 0xff && (start_bytes[3] & 0xf0) == 0xe0)
    {
        return true;
    }

    return false;
}
  • 포렌식 파일 포인터를 인자로 받는다.
  • fread()를 호출하여 BYTE 배열 start_bytes에 첫 4바이트 내용을 저장
  • fread()가 실행된 이후에는 포인터가 이동하므로 fseek()를 호출해 포인터 위치를 원래대로 되돌린다.
  • start_bytes에 저장한 첫 4바이트가 모두 JPEG 시그니처와 일치하면 true를 반환하고, 그렇지 않다면 false를 반환한다.

블록 내용 복사

void copy_block(FILE *inptr, FILE *outptr)
{
    BYTE buffer[BLOCK_SIZE];
    fread(buffer, 1, BLOCK_SIZE, inptr);
    fwrite(buffer, 1, BLOCK_SIZE, outptr);
}
  • 포렌식 파일 포인터와 복사본 파일 포인터를 인자로 받는다
  • fread()를 이용해 buffer 변수에 블록의 내용을 임시 저장한 뒤 fwrite()에 buffer의 내용을 덧붙인다.


✍️ 메모

정적 vs 동적 메모리

char outfile[8];
BYTE buffer[512];

파일명을 저장하는 outfile 변수와 블록의 내용을 저장하는 buffer 변수에는 계속해서 다른 값이 할당되어서 malloc을 써야 하는지 헷갈렸다.

찾아보니 이 두 변수는 1) 컴파일 시에 변수 크기가 확정되며 2) 메모리의 크기가 크지 않기 때문에 메모리 관리를 해주어야 하고 비교적 느린 malloc을 굳이 쓸 필요가 없는 것 같아 정적 메모리를 사용했다.

파일 처리 함수

이번 과제는 어려운 부분은 없었는데 파일 처리 함수에 대해 잘 몰라서 시간을 많이 썼다. 파일 포인터를 이동시키면서 포렌식 파일의 각 블록에 접근하려고 했지만 원하는 값이 나오지 않았다. 두 가지 원인이 있었는데, 모두 내가 함수에 익숙하지 않은 탓이었다.

첫 번째는 fread()가 값을 읽은 뒤에 그 마지막으로 파일 포인터가 이동된다는 것을 몰랐기 때문이었다. isJPEG() 함수에서 start_bytes에 블록의 첫 네 바이트 값을 저장한 뒤 블록의 첫 위치로 포인터를 돌려놓지 않은 것이 문제였다.

두 번째는 fseek()의 모드를 SEEK_SET으로 지정해야 했는데 SEEK_CUR로 쓴 문제였다. 반복문에서 파일 포인터의 첫 부분으로부터 512 * i 만큼 떨어진 위치로 이동시켜야 하기 때문에 SEEK_SET을 써야하는데, SEEK_CUR을 쓰니까 현재 위치에서 512 * i 떨어진 위치로 이동시키는 것을 반복하니 segmentation fault 오류가 났다.




Reference

post-custom-banner

0개의 댓글