이번 CTF의
웹 분야 1번 문제 입니다.
# 개요
전형적인 SSRF 문제로
내부에서만
접근 가능한 페이지에
접속하여
FLAG를 획득하는 문제
입니다.
# 환경 구성
문제 파일을 다운받으면,
Docker Image의 내용이 명시된
Dockerfile이 있습니다.
테스트를 위해
도커 이미지를 생성하고
생성된 이미지로
도커 컨테이너를 실행 시킵니다.
# Dockerfile이 존재하는 디렉토리/폴더로 이동
# Docker Image 생성
> docker build -t baby-simple .
# 생성된 이미지 확인
> docker images
# 생성된 이미지 설치 및 실행
# Port Binding: 외부 PORT 11000 -> 내부 PORT 8080
> docker run -p 11000:8080 -d baby-simple
# 실행 중인 도커 컨테이너 확인
> docker ps
"docker ps" 명령어로
풀이 대상의
도커 컨테이너를 확인할 수 있습니다.
도커가 실행 중인 서버(호스트)의
IP와 PORT로 접속하면
문제 사이트를 확인할 수 있습니다.
# 분석
문제로 제공된 파일들 중
중요한 파일은
Dockerfile, main.go
파일입니다.
+ Dockerfile
Docker Image의 내용을
기입하는 파일입니다.
파일의 내용을 보면
웹 서비스를 제공하고자
GO 언어 환경을 구성하기 하지만
문제 풀이에 필요한 FLAG 및
SSRF에서 사용할 내부 PORT 정보가
담겨 있습니다.
FROM golang:1.19
ENV FLAG = "LINECTF{redacted}" # 환경변수에 FLAG 설정
ENV GOCURL /usr/local/opt/gocurl
RUN mkdir -p "${GOCURL}"
RUN apt-get -qq update && \
apt-get -qq -y upgrade && \
apt-get -qq -y install htop net-tools vim curl
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64
COPY ./baby-simple-gocurl "${GOCURL}"
COPY start.sh "${GOCURL}/start.sh"
WORKDIR "${GOCURL}"
RUN go mod download
RUN go build -o main .
RUN chmod -R 705 "${GOCURL}"
RUN groupadd -g 1000 gocurl
RUN useradd -g gocurl -s /bin/bash gocurl
USER gocurl
EXPOSE 8080 # 내부 PORT 정보
WORKDIR "${GOCURL}"
ENTRYPOINT ["./start.sh"]
+ main.go: "/flag/"
우선 FLAG는
환경변수에서 불러온 후,
"/flag/" 페이지에 접속했을 때
확인할 수 있습니다.
다만, 접속자(Client)의 IP가
127.0.0.1 로
즉, 서버에서 접속해야만
FLAG를 확인할 수 있습니다.
func main() {
// 환경 변수에서 FLAG 저장
flag := os.Getenv("FLAG")
...
// /flag/ 페이지 내용
r.GET("/flag/", func(c *gin.Context) {
// 접속자 IP(Client IP) 획득
reqIP := strings.Split(c.Request.RemoteAddr, ":")[0]
log.Println("[+] IP : " + reqIP)
// 접속자 IP가 127.0.0.1 이어야 함
if reqIP == "127.0.0.1" {
c.JSON(http.StatusOK, gin.H{
"message": flag, // flag 획득
})
return
}
c.JSON(http.StatusBadRequest, gin.H{
"message": "You are a Guest, This is only for Host",
})
})
r.Run()
}
+ main.go: "/curl/"
이 /curl/ 페이지는
윈도우의 CURL 명령어 처럼
입력된 URL 주소와
HTTP Header 정보를 가지고
HTTP 패킷을 생성, 접속하여
그 응답값을 보여주는 페이지입니다.
다만, 몇가지 조건이 있습니다.
조건1: 접속자 IP 및 URL의 path 필터링
- 127.0.0.1에서만 /flag/ 라우트를 사용할 수 있으며,
/curl/ 라우트에 전달되는 URL에는
"flag", "curl", "%"(URL 인코딩 문자)과 같은 문자열이 포함될 수 없습니다.
조건2: 이전 요청의 IP 필터
- redirectChecker 함수는 via의 길이가 2 이상이거나,
이전 요청의 IP 주소가 "127.0.0.1"이 아닌 경우 에러를 반환합니다.
// redirectChecker 함수
func redirectChecker(req *http.Request, via []*http.Request) error {
/**
/* via는 HTTP 리다이렉트 체인의 이전 요청들을 저장하는 슬라이스입니다.
/* via[len(via)-1]은 현재 요청 이전의 요청을 나타냅니다.
/* 이전 요청의 IP 주소를 가져와서 reqIp 변수에 저장합니다.
/**/
reqIp := strings.Split(via[len(via)-1].Host, ":")[0]
if len(via) >= 2 || reqIp != "127.0.0.1" {
return errors.New("Something wrong")
}
return nil
}
func main() {
...
r.GET("/curl/", func(c *gin.Context) {
/**
/* client는 아래 입력값 필터링(검증)이 끝난 후,
/* 지정된 URL로 접속하여 그 결과를 가져올 때 사용됨
/**/
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return redirectChecker(req, via)
},
}
reqUrl := strings.ToLower(c.Query("url"))
reqHeaderKey := c.Query("header_key")
reqHeaderValue := c.Query("header_value")
reqIP := strings.Split(c.Request.RemoteAddr, ":")[0]
fmt.Println("[+] " + reqUrl + ", " + reqIP + ", " + reqHeaderKey + ", " + reqHeaderValue)
/* 입력값 검증 */
/**
/* 접속자 IP(ClientIP)가 127.0.0.1이 아닌 경우,
/* 입력된 URL에 flag, curl, % 등이 있으면
/* Something Wrong 임
/**/
if c.ClientIP() != "127.0.0.1" && (strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%")) {
c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
return
}
/* HTTP Request 생성 */
req, err := http.NewRequest("GET", reqUrl, nil)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
return
}
/* HTTP Request Header 입력 */
if reqHeaderKey != "" || reqHeaderValue != "" {
req.Header.Set(reqHeaderKey, reqHeaderValue)
}
/* Send HTTP Request & Receive HTTP Response */
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
return
}
...
})
...
r.Run()
}
단, 위 코드에서 주의할 점은
일반적으로 개발 언어에 상관 없이
조건문에서 AND 연산의 경우
프로그램의 효율을 위해
앞의 결과가 FALSE인 경우,
어차피 전체 결과 또한 FALSE이기 때문에
뒤의 결과를 확인하지 않는다는
규칙이 존재합니다.
// 앞이 이미 False 이므로, 뒤는 확인하지 않는다.
if 1==0 && 1==fmt.Println("실행되지 않음") { ... }
// OR은 반대임(앞이 True면, 뒤에 상관없이 전체가 True이기 때문)
if 1==1 || 0==fmt.Println("실행되지 않음") { ... }
따라서, 위의 /curl/ 페이지에 존재하는 필터링의 경우
접속한 Client IP가 서버에서 127.0.0.1로 인식되면
뒤의 문자열 필터링은 생략됩니다.
# 풀이
이 문제를 풀기 위해서는
두 가지 조건을 충족시켜야 합니다.
조건 1: /flag/ 페이지는 서버에서만 접속 가능
- /flag/ 페이지에 접속을 해야하는 데
이 페이지는 127.0.0.1
즉, 서버에서만 접속이 가능
조건 2: /curl/ 페이지 필터링 우회
- 서버에서 접속한 Client IP가 127.0.0.1로 인식되어야 함
위의 조건을 충족시키기 위해
아래와 같은 방법을 사용했습니다.
조건 1 우회: /curl?url=http://127.0.0.1:8080/flag/
- curl 페이지를 사용하면 서버에서 /flag/ 페이지에 접속하게 되며,
그 결과를 사용자에게 반환
조건 2 우회: X-Forwarded-For Header
- HTTP Request의 X-Forwarded-For header를 사용하여
Client의 IP를 127.0.0.1 로 인식
* X-Forwarded-For Header:
- HTTP 프록시나 로드 밸런서를 통해
웹 서버에 접속하는 Client의 IP 주소를 식별하는 표준 헤더
위의 우회방안을 조합하여
아래와 같이 서버로 요청하면
FLAG를 획득할 수 있습니다.