Go

Go 실행 파일에 ZIP으로 리소스 임베딩하기: 간단하게 알아볼까요?

드리프트2 2024. 9. 19. 22:24

Go 실행 파일에 ZIP으로 리소스 임베딩하기: 간단하게 알아볼까요?

시작하며

ZIP을 사용하여 실행 파일에 리소스를 임베딩하는 방법을 소개하려고 한데요.

 

실제로 Go의 archive/zip, zip 명령어, cat 명령어(확인을 위해 unzip 명령어도 함께)를 사용하여 리소스를 임베딩하는 예제를 설명해드리겠습니다.


Go에서 리소스 임베딩

Go로 애플리케이션을 개발할 때, CSS, JavaScript, 이미지, 템플릿 등의 리소스를 실행 파일과 별도로 배치하거나 실행 파일에 임베딩해야 합니다.

 

리소스를 임베딩하는 방법에는 코드 제너레이터를 사용하는 방법과 ZIP을 사용하는 방법이 있는데요.

코드 제너레이터

리소스 임베딩에서 자주 소개되는 방법은 코드 제너레이터를 사용하여 리소스를 Go 소스 코드로 변환하는 것입니다.

 

실제로 Awesome Go의 Resource Embedding 항목에 있는 것은 코드 제너레이터를 사용하여 Go 소스 코드로 변환하는 접근법들뿐입니다.

ZIP

한편, 리소스를 ZIP 파일로 묶어서 실행 파일에 추가하는 임베딩 방법도 있습니다.

 

이미 Zgok이라는 라이브러리가 있습니다.

ZIP 파일의 사양에 따르면, self-extracting ZIP 파일은 대상 플랫폼별로 추출 코드를 포함해야 한다고 합니다.

4.1.9 ZIP files MAY be streamed, split into segments (on fixed or on removable media) or "self-extracting". Self-extracting ZIP files MUST include extraction code for a target platform within the ZIP file.

 

또한, zip 명령어의 매뉴얼 페이지에는 self-extracting 실행 파일 겸 아카이브는 기존 아카이브에 SFX 스텁(stub)을 앞에 붙여서 만든다고 합니다.

A self-extracting executable archive is created by prepending the SFX stub to an existing archive.

 

이러한 점에서, 실행 파일 자체를 ZIP 파일로 취급하는 Go 프로그램을 작성하고, 그 실행 파일의 끝에 ZIP 파일을 추가하면 리소스를 임베딩할 수 있다는 것을 알 수 있습니다.¹


ZIP으로 리소스 임베딩 예제

여기서는 main.goassets/templates/hello.tmpl로만 구성된 간단한 예제를 생각해보겠습니다.

$ tree .
.
├── assets
│   └── templates
│       └── hello.tmpl
└── main.go

2 directories, 2 files

 

assets/templates/hello.tmpl은 아래와 같이 단순한 템플릿입니다.

Hello, {{.}}!

 

또한, main.go는 자신의 실행 파일 os.Executable()을 ZIP 파일로 읽어들여 임시 디렉토리에 압축 해제합니다.

 

그 후, 추출된 리소스 중 템플릿을 사용하여 Hello, World!를 출력합니다.

 

archive/zip을 사용할 때 주의할 점은 아카이브 안의 경로에 ..가 없는지 확인해야 한다는 것입니다.

..를 허용하면 임의의 명령을 실행할 수 있는 취약점으로 이어질 수 있습니다.

package main

import (
    "archive/zip"
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "os"
    "path/filepath"
    "strings"
    "text/template"
)

func main() {
    exec, err := os.Executable()
    if err != nil {
        log.Fatalf("실행 파일을 가져오지 못했습니다: %v", err)
    }

    r, err := zip.OpenReader(exec)
    if err != nil {
        log.Fatalf("ZIP 리더를 가져오지 못했습니다: %v", err)
    }
    defer r.Close()

    dir, err := ioutil.TempDir("", filepath.Base(exec))
    if err != nil {
        log.Fatalf("임시 디렉토리를 생성하지 못했습니다: %v", err)
    }
    defer os.RemoveAll(dir)

    for _, f := range r.File {
        if err := extract(f, dir); err != nil {
            log.Fatalf("파일을 추출하지 못했습니다: %v", err)
        }
    }

    log.Printf("assets: %s", dir)

    t := template.Must(template.ParseGlob(filepath.Join(dir, "templates", "*")))
    if err := t.ExecuteTemplate(os.Stdout, "hello.tmpl", "World"); err != nil {
        log.Fatalf("t.ExecuteTemplate() 실패: %v", err)
    }
}

func extract(f *zip.File, dir string) error {
    if strings.Contains(f.Name, "..") {
        // Zip Slip 공격!
        return fmt.Errorf("파일 경로 '%s'에 '..'이 포함되어 있습니다", f.Name)
    }

    path := filepath.Join(dir, filepath.Clean(f.Name))

    if f.Mode().IsDir() {
        if err := os.MkdirAll(path, f.Mode()); err != nil {
            return err
        }
        return nil
    }

    r, err := f.Open()
    if err != nil {
        return err
    }
    defer r.Close()

    tf, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
    if err != nil {
        return err
    }
    defer tf.Close()

    _, err = io.Copy(tf, r)
    return err
}

실행 파일 생성

먼저 일반적인 방법으로 실행 파일을 생성합니다.

$ go build -o hello

 

여기에는 리소스가 임베딩되어 있지 않으므로 반드시 실패하게 됩니다.

$ ./hello
2018/11/25 17:15:32 ZIP 리더를 가져오지 못했습니다: zip: not a valid zip file

ZIP 파일 생성

다음으로 리소스를 묶은 ZIP 파일을 생성합니다.

zip 명령어는 실행 시의 현재 디렉토리를 기준으로 하기 때문에 assets 디렉토리 안에서 실행해야 합니다.

$ cd assets
$ zip -r ../assets.zip .
  adding: templates/ (stored 0%)
  adding: templates/hello.tmpl (stored 0%)
$ cd ..

 

정상적으로 만들어진 리소스 ZIP 파일에는 경로에 ..assets가 포함되지 않습니다.

$ unzip -l assets.zip
Archive:  assets.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  11-25-2018 16:13   templates/
       14  11-05-2018 21:31   templates/hello.tmpl
---------                     -------
       14                     2 files

실행 파일과 ZIP 파일의 결합

다음으로, 실행 파일과 ZIP 파일을 연결합니다.

$ cat hello assets.zip > hello-bundled
$ chmod +x hello-bundled

 

단순히 연결한 것만으로는 ZIP 파일의 오프셋 값이 실행 파일의 크기만큼 어긋나 있기 때문에 아직 실패합니다.

$ ./hello-bundled
2018/11/25 17:16:37 ZIP 리더를 가져오지 못했습니다: zip: not a valid zip file
$ unzip -l hello-bundled
Archive:  hello-bundled
warning [hello-bundled]:  3309528 extra bytes at beginning or within zipfile
  (attempting to process anyway)
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  11-25-2018 16:13   templates/
       14  11-05-2018 21:31   templates/hello.tmpl
---------                     -------
       14                     2 files

 

여기에서 zip -A (--adjust-sfx)를 사용하면 오프셋 값을 조정할 수 있습니다.

$ zip -A hello-bundled
Zip entry offsets appear off by 3309528 bytes - correcting...

 

조정이 완료되면 self-extracting ZIP 파일이 되므로 성공합니다.

$ ./hello-bundled
2018/11/25 17:17:12 assets: /var/folders/xt/6z9sk1dx1d734ltxxst16_h00000gn/T/hello-bundled725779200
Hello, World!

마치며

ZIP을 사용한 리소스 임베딩 방법을 소개하고, archive/zip을 사용한 리소스 임베딩 예제를 설명했습니다.

 

실제로 이 임베딩 방법을 사용하면 go run이나 go test가 그대로는 동작하지 않습니다.

 

또한, 리소스를 교체하려면 빌드 과정을 다시 수행해야 합니다.

 

더욱 간편한 인터페이스로 go run 시나 go test 시에 폴백(fallback)과 환경 변수를 사용한 리소스 교체를 지원하는 assets라는 라이브러리를 만들어 보았으니, 한번 시도해보시면 좋겠습니다.