EMD Blog

[Tip] Google Drive 랜섬웨어 복구 (with Python) 본문

Public Cloud/GCP

[Tip] Google Drive 랜섬웨어 복구 (with Python)

EmaDam 2024. 7. 13. 14:06
반응형

안녕하세요. Emadam입니다.

 

얼마 전 회사에서 저희 팀에서 랜섬웨어가 걸리는 사태가 발생했습니다.

랜섬웨어에 걸린 PC는 구글 드라이브를 네트워크로 연결해서 사용하고 있었기 때문에 해당 PC에 연결되어 있던 모든 공유 드라이브의 접근 가능한 파일들은 전부 암호화되어 버렸습니다.

 

이렇게 랜섬웨어로 인해 구글 드라이브의 파일이 암호화되는 경우 크게 두 가지 방식으로 암호화가 될 수 있습니다.

  • 모든 파일을 삭제 후 암호화된 파일을 삽입
  • 파일을 삭제하지 않고 암호화

위에서 어떤 방식으로 암호화가 되었든 복구는 가능합니다. 전자의 경우 삭제된 파일이 휴지통에 있기 때문에 휴지통에서 복구하면 되고 후자는 파일 버전을 이전 버전으로 되돌리면 됩니다.

또한 구글 드라이브는 검색이 편리하게 되어 있기 때문에 파일 수가 그렇게 많지 않다면 파일을 검색해서 수동으로 복구해도 크게 시간이 오래 걸리지 않습니다.

그렇다면 수동으로 도저히 복구가 불가능 할 정도로 파일이 많다면 어떻게 해결해야 할까요?

해결법

구글에서는 아래와 같이 랜섬웨어 대응에 대한 문서를 제공해주고 있습니다.

위 내용을 요약하면 다음과 같습니다. (기본 보안 지침 제외)

  • 원본 파일이 휴지통에 있는지 확인합니다.
  • 원본 파일이 휴지통에 있다면 휴지통에 있는 파일을 복구합니다.
  • 원본 파일이 암호화되었다면 Google Drive API를 사용해 암호화된 버전을 제거합니다.

위 두 가지 암호화 유형에 대해 간단한 파이썬 스크립트를 사용해서 복구 할 수 있습니다.

휴지통에 있는 파일 복구

휴지통에 있는 파일도 Google Drive API를 사용해 복구가 가능합니다. 다만, 휴지통에 있는 파일 복구 시 복구 순서에 주의해야 합니다.

Google Drive API의 경우 삭제된(휴지통으로 이동된) 날짜를 기준으로 쿼리가 불가능하기 때문에 암호화된 파일을 먼저 삭제하고 원본 파일을 복구 할 경우 전부 수동으로 파일을 복구해야합니다.

따라서 아래와 같은 순서로 복구해야 합니다.

  1. 휴지통에 있는 파일을 정리합니다. (Google Drive)
  2. 휴지통의 파일을 전부 복구합니다.
  3. 암호화된 파일을 필터링해서 전부 제거합니다.

휴지통 파일은 날짜로 쿼리가 되지 않기 때문에 수동으로 해줘야합니다. 삭제 날짜로 정렬해서 불필요한 파일은 사전에 제거해둡니다.

휴지통의 파일을 전부 정리했다면 아래 메서드를 사용해 파일을 복구 할 수 있습니다.

위 두 메서드를 사용해 파일을 가져오고 가져온 파일들을 복구할 수 있습니다.

먼저 삭제된 파일을 조회합니다.

def get_deleted_files(drive_id, pageToken):
  result = {
    'files' : [],
    'nextPageToken' : None
  }

  try:
      service = build("drive", "v3", credentials=_credentials())

      results = (
          service.files()
          .list(
              q="trashed=true",
              corpora="drive",
              pageSize=1000,
              driveId=drive_id,
              includeItemsFromAllDrives=True,
              supportsAllDrives=True,
              pageToken=pageToken,
              fields="nextPageToken, files(id, name)"
          )
          .execute()
      )
      result['files'] = results.get("files", [])
      result['nextPageToken'] = results.get("nextPageToken", None)

  except HttpError as error:
      print(f"An error occurred: {error}")

  return result

위 코드는 list 메서드를 호출할 때마다 1000행의 결과를 반환합니다. list 메서드는 결과를 반환할 때 nextPageToken을 같이 반환하는데 이 token을 다음 요청때 매개변수로 전달하게 되면 자동으로 다음 1000행을 반환하게 됩니다. 즉, nextPageToken를 매개변수로 넣어서 결과가 더는 조회되지 않을 때까지 호출을 반복하면 전체 파일목록을 순회할 수 있게 됩니다.


참고로 includeItemsFromAllDrives 옵션과 supportsAllDrives 옵션은 공유 드라이브를 조회하기 위해 설정된 옵션입니다.

 

파일을 조회했다면 조회된 파일들의 ID를 사용해 파일을 공유 드라이브로 복구해야 합니다. 복구 로직은 아래와 같습니다.

def restore_files(file_id):
  try:
      service = build("drive", "v3", credentials=_credentials())

      results = (
          service.files()
          .update(
              fileId=file_id,
              supportsAllDrives=True,
              body={
                'trashed' : False
              }
          )
          .execute()
      )

  except HttpError as error:
      print(f"An error occurred: {error}")

복구는 조회보다 더 간단하게 구현 가능합니다. update 메서드에 fileId를 지정하고 body에 {'trashed' : False }를 전달하면 됩니다.

파일을 복구했다면 이제 암호화된 파일을 조회하고 제거해야 합니다. 휴지통 복구때와 동일하게 아래 두 API를 사용해서 해결할 수 있습니다.

def get_files(drive_id, page_token):
    result = {
      'files' : [],
      'nextPageToken' : None
    }

    try:
        service = build("drive", "v3", credentials=_credentials())

        results = (
            service.files()
            .list(
                q="trashed=false and name contains '" + FILTER + "'",
                corpora="drive",
                pageSize=1000,
                driveId=drive_id,
                includeItemsFromAllDrives=True,
                supportsAllDrives=True,
                pageToken=page_token,
                fields="nextPageToken, files(id, name)"
            )
            .execute()
        )
        result['files'] = results.get("files", [])
        result['nextPageToken'] = results.get("nextPageToken", None)

    except HttpError as error:
        print(f"An error occurred: {error}")

    return result

휴지통 파일 조회때와 동일해 보이지만 다른 부분이 두 가지 있습니다. 하나는 query의 trashed를 false로 조회하고 있고 contains로 파일 명에 FILTER 값이 포함되어 있는 지 조회하고 있습니다.

조회된 파일 ID를 사용해 해당 파일을 휴지통으로 보냅니다.

def delete_files(drive_id ,file_id):
  try:
      service = build("drive", "v3", credentials=_credentials())

      results = (
          service.files()
          .update(
              fileId=file_id,
              supportsAllDrives=True,
              body={
                'trashed' : True
              }
          )
          .execute()
      )

  except HttpError as error:
      print(f"An error occurred: {error}")

만약 암호화된 파일을 휴지통으로 보내지 않고 바로 영구삭제하려면 아래 메서드를 사용해주세요.

  • files.delete
    하지만 가급적 휴지통 먼저 이동 시킨 후 아래 메서드를 사용해 휴지통을 비우는 것을 권장드립니다. (예상치 못한 파일 삭제 방지)
  • files.emptyTrash

변경된 원본 파일 복구

만약 휴지통에 파일이 없을 경우 원본 파일이 암호화되었을 수 있습니다. 스크립트 작성 전에 변조가 의심되는 파일 몇 개를 수동으로 버전을 변경해서 정상으로 되돌아 오는지 확인해주세요.
정상적으로 되돌아 온다면 아래 세 메서드를 통해 전체 파일을 복구할 수 있습니다.

먼저 파일 목록을 불러옵니다.

def get_files(drive_id, page_token):
    result = {
      'files' : [],
      'nextPageToken' : None
    }

    try:
        service = build("drive", "v3", credentials=_credentials())

        results = (
            service.files()
            .list(
                q="trashed=false and name contains '" + FILTER + "'",
                corpora="drive",
                pageSize=1000,
                driveId=drive_id,
                includeItemsFromAllDrives=True,
                supportsAllDrives=True,
                pageToken=page_token,
                fields="nextPageToken, files(id, name)"
            )
            .execute()
        )
        result['files'] = results.get("files", [])
        result['nextPageToken'] = results.get("nextPageToken", None)

    except HttpError as error:
        print(f"An error occurred: {error}")

    return result

휴지통 복구 때와 동일하게 삭제되지 않은 파일 중 이름에 FILTER 값이 포함된 파일을 전부 조회합니다.
그 다음 파일 ID를 사용해 파일의 버전 목록을 확인합니다.

def get_revisions(file_id):
    result = {
      'revisions' : []
    }

    try:
        service = build("drive", "v3", credentials=_credentials())

        results = (
            service.revisions()
            .list(
                fileId=file_id,
                fields="revisions(id, modifiedTime)"
            )
            .execute()
        )
        print(results)
        result['revisions'] = results.get("revisions", [])

    except HttpError as error:
        print(f"An error occurred: {error}")

    return result

위 코드에서는 따로 페이징을 하지 않았지만 버전이 많은 경우 pageSize, pageToken, nextPageToken을 사용해서 페이징이 가능합니다.
정렬을 하면 가장 좋겠지만 안타깝게도 정렬은 제공하지 않는 것으로 보입니다.
위 함수를 실행하면 각 파일별로 revisionId 목록을 얻을 수 있고 이 각 파일의 revisionId 목록 중 제일 마지막 revision을 제거해주시면 됩니다.

def delete_revision(file_id, revision_id):
    try:
        service = build("drive", "v3", credentials=_credentials())

        results = (
            service.revisions()
            .delete(
                fileId=file_id,
                revisionId=revision_id
            )
            .execute()
        )

    except HttpError as error:
        print(f"An error occurred: {error}")

위 함수에 file_id와 revision_id를 넣어주시면 해당 revision을 삭제할 수 있습니다. 하지만 중요한 점은 바이너리 콘텐츠(예: 이미지, 동영상)가 포함된 파일의 버전만 삭제 가능하다는 것 입니다. Google Docs나 Sheets, 버전이 하나만 남은 파일의 마지막 버전은 제거가 불가능하니 주의하셔야 합니다. (삭제를 시도할 경우 에러가 발생합니다.)

 

아래 저장소는 제가 임시로 만들어서 사용한 코드 입니다. 해당 코드에서 delete_encrypted_version, delete, restore 함수를 사용해 작업을 진행했었습니다. 혹시 사용하실 분들은 사전에 꼭 테스트 후 사용해주세요.

- ldy9037/google-drive-restore

 

마무리

 스크립트를 작성해서 실행시키는 배치 방식의 장점은 다른 업무와 병행이 가능하다는 것 입니다. 이런 장점은 스크립트를 작성하고, 테스트하고, 실행시키는 번거로움을 충분히 감수할 만한 가치가 있다고 생각합니다.  만약 랜섬웨어에 감염된 파일이 10,000개가 넘어간다면 주저하지 말고 Google Drive API를 사용해 배치 스크립트를 작성하는 것을 추천드립니다. 

 

이렇게 Google Drive API를 사용해서 랜섬웨어에 의해 암호화된 파일을 복구 방법을 공유해드렸는데 랜섬웨어로 고민하고 계신 분들께 도움이 되었으면 좋겠습니다. 

 

감사합니다!

 

반응형