티스토리 뷰


개인적으로 vultr.com 이라는 VPS업체에서 5달러짜리 2대 및 40달러짜리 1대의 VPS를 사용중이고,

그 밖에 ArchLinuxARM 이 올려진 싱글보드 2대와 mac mini server를 사용중이다.

이 서버들의 ipv4 A레코드와 ipv6 AAAA레코드, CNAME, MX레코드 등등을 관리하려다보니

기존에 사용중이던 dnszi.com 으로는 AAAA레코드 미지원등 몇가지 부분에서 내가 원하는 방향과 맞지 않아

DNS 서비스만 보고 CF(Cloudflare)를 사용하기로 결심하고 넘어갔다.


그런데 사용해보니 리버스 프록시를 사용함으로써 얻는 이득이 생각보다 좋은 것 같았다.

일단 글로벌 CDN이 된다는 것과 캐싱.

SSL같은 경우는 기존의 도메인에 모두 Letsencrypt 를 통한 TLSv12 인증서를 이미 발급받아 사용중이였기에 큰 감흥은 없었다.


어쨋든, 리버스 프록시를 쓰면서 만족을 하다보니... 인간의 욕심은 끝이 없다는 말이 틀림 없는 것이,

보통 WAF(Web Application Firewall)에 걸려서 위 스크린샷과 같은 DDoS 방어 페이지가 뜰 때의 문제가 생기는데...

그걸 해결해보고 싶어졌다.


위 상황이 되면 이런 문제가 발생한다.

1. 앱은 WAS에 붙은 후 필요한 리소스를 요청할 것이다.

2. 하지만 내 요청은 WAS에 닿기 전에 CF에 잡히고, WAF에서 거절당한다.

3. WAF는 브라우저 JS를 이용해 정상적인 브라우저를 통한 페이지접근인지 확인하기 위해 위의 페이지를 응답한다.

4. 앱은 application/json을 요청했지만 응답은 text/html; utf-8 이 내려왔기 때문에 JSON Serializer는 파싱에 실패한다.

5. 앱의 통신모듈은 Exeption Error를 뱉고 해당 통신을 종료한다.


결론. 안된다는 것이다.



일단 CF가 JS를 통해 어떻게 정상적인 브라우저인지 검증하는가가 중요한데,

우선 페이지를 접근하면 5초간 기다리고, (DDoS 공격의 다수의 연결을 CF가 5초간 잡고 있다가 5초 이후에도 계속 연결되어 있는 경우에만 원본 서버까지 전달한다. 때문에 공격성 연결은 5초를 대기 하지 않고 새 연결을 생성할 것이기 때문에 대부분의 공격 트래픽이 이 단계에서 걸러질 것이라 예상된다.)

그 후 Javascript를 통해 쿠키 2개를 굽는다. 이때 사용자 브라우저의 User-Agent도 함께 검증한다. DDoS 방어 페이지에서 검출된 UA와 쿠키 생성후 다시 요청된 UA가 다른 경우에도 비정상 요청으로 판단하여 차단된다.


위의 사항만 알면 구현은 어렵지 않다. 잠시 머릿속에 설계를 그려보았다.

1. 웹뷰를 화면에 띄워야 한다. 별도의 xib 또는 storyboard를 쓰지 않아도 될 정도로 간단하게 UIViewController를 상속받은 클래스에 코드로 self.view = UIWebView()로 하면 될 것 같다.

2. Alamofire가 사용하는 User-Agent는 앱에서 직접 만들어서 쓰고 있는데 이 것을 WebView에도 똑같이 넣어주면 되겠다.

3. 쿠키가 구워진 것을 확인하면, 이 클래스를 호출한 메소드에게 콜백을 주고싶다. 블럭코드를 사용하면 편하겠다.


먼저 위 사항대로 기본적인 뼈대를 만들었다.

public class CFDDosWebViewController: UIViewController, UIWebViewDelegate {
    public typealias Completion = (([HTTPCookie]) -> Void)

    private let webView = UIWebView()
    private var completion: Completion?

    override public func viewDidLoad() {
        super.viewDidLoad()
        
        self.title = "Cloudflare"
        self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel,
                                                                 target:              self,
                                                                 action:              #selector(self.dissmissByCancel(sender:)) )
        
        self.view = self.webView
        self.webView.isOpaque        = false
        self.webView.backgroundColor = .white
    }

    @objc private func dissmissByCancel(sender: UIBarButtonItem) {
        self.dismiss(animated: true, completion: nil)
    }
    
    public func webView(_ webView: UIWebView, shouldStartLoadWith request: URLRequest, navigationType: UIWebViewNavigationType) -> Bool {
        return true
    }
}


간단하게 URLString과 User-Agent만 파라메터로 주면 웹뷰를 동작시키도록 메소드를 추가해보자.

    private func loadRequest(_ urlString: String, _ userAgent: String, completion: Completion?) {
        guard let url = URL(string: urlString) else { return }
        var request   = URLRequest(url: url)
        
        self.completion = completion
        UserDefaults.standard.register(defaults: ["UserAgent": userAgent])
        request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
        
        self.webView.delegate = self
        self.webView.loadRequest(URLRequest(url: url))
    }


Delegate에 self를 지정했으므로 Bool webView(_: shouldStartLoadWith: navigationType:) 가 동작 할 것이다.

이 델리게이트는 웹뷰에 페이지 로딩이 발생하려 할 때 호출되며, 리턴값이 true면 로드를 시작하고, false면 무시된다. 이 것  까지 염두해서 구현해보자.

    public func webView(_ webView: UIWebView, shouldStartLoadWith request: URLRequest, navigationType: UIWebViewNavigationType) -> Bool {
        if let requestURL = request.url, let cookies = HTTPCookieStorage.shared.cookies(for: requestURL), self.checkCFCookie(url: requestURL) {
            self.completion?(cookies)
            self.dismiss(animated: true, completion: nil)
            return false
        }
        return true
    }
    private func checkCFCookie(url: URL) -> Bool {
        if let cookies = HTTPCookieStorage.shared.cookies(for: url) {
            let isFindCookieCFClearance = cookies.filter{ $0.name == "cf_clearance" }.count > 0
            let isFindCookieCFDUID      = cookies.filter{ $0.name == "__cfduid"     }.count > 0
            if isFindCookieCFClearance, isFindCookieCFDUID {
                return true
            }
        }
        return false
    }


델리게이트 동작시 self.checkCFCookie 메소드를 호출하고 결과값에 따라 분기되도록 했다.

checkCFCookie 메소드를 살펴보면, iOS의 HTTPCookieStorage 안에서 "cf_clearance""__cf_duid"라는 이름의 쿠키가 존재하는지 확인한다.

결론은 위의 2개의 쿠키가 생성되어 있다면 페이지 로드를 시작하지 않고 completion 블럭을 실행하고 종료하라는 의미이다.

CF-WAF는 브라우저 검증을 위해 위 2개의 쿠키를 생성한다.


    public static func show(url: String, userAgent: String, closure: @escaping Completion) {
        let navigationController = UINavigationController()
        let cfController         = CFDDosWebViewController()
        navigationController.viewControllers = [cfController]
        UIViewController.presented.last?.present(navigationController, animated: true, completion: {[weak cfController] in
            for cookie in HTTPCookieStorage.shared.cookies ?? [] {
                HTTPCookieStorage.shared.deleteCookie(cookie)
            }
            
            cfController?.loadRequest(url, userAgent) {cookies in
                closure(cookies)
            }
        })
    }

작성한 뷰컨트롤러를 어디서든 바로 present 하고 위 모든 작업이 완료되었을 때 completion 블럭 까지 실행해 주도록 static 메소드를 추가했다.

이제 어디서든 CFDDoSWebViewController.show(url: userAgent: ) 를 호출하여 사용할 수 있게 됐다.

UIViewController.presented 라는 프로퍼티는 개인적으로 구현한 것으로 현재 앱에서 present한 모든 UIViewcontoller를 배열로 담고 있다.

UIViewController.presented.last 를 사용하고 있기 때문에 현재 사용자가 보는 가장 마지막에 present 된 뷰컨트롤러를 get 한다.


이제 Alamofire에서 API요청시 WAF에 걸렸을 때 분기를 만들어서 지금 만든 코드를 실행하도록 하면 될 것이다.

            switch response.result {
            case .failure(let error):
                print("ERROR: \(error)")
                if let contentType = response.response?.allHeaderFields["Content-Type"] as? String, contentType.contains("text/html;") {
                    print("DETECT PROTECT DDOS IN CF-WAF.")
                    CFDDosWebViewController.show(url: url, userAgent: CoreClient.userAgent) {cookies in
                        Alamofire.SessionManager.default.session.configuration.httpCookieStorage?.setCookies(cookies, for: URL(string: url), mainDocumentURL: nil)
                        CoreClient.request(method, url, parameters, closure: closure)
                    }
                } else {
                    closure?(error, nil)
                }
            case .success(let responseDict):
                /* blah blah 성공했을 때의 처리 */
            }

위 코드는 실제로 앱 내 모든 API 통신을 요청할 때 거쳐가는 CoreClient 라고 이름 붙인 클래스의 메소드 일부이다.

Alamofire에게 요청을 보내고, 응답을 받은 후 처리를 한다.

여기서 result가 failure 일때 분기에서 Content-Type에 text/html 이 검출될 때 분기를 사용했다.

더 좋은 방법이 있을 수 있겠지만, 일단 API는 모두 application/json으로 응답 받아야만 하는데 이에 벗어난 상황이니 WAF에 걸린 상태로 판단했다.

다른 훌륭한 방법이 있다면 그 방법으로 분기 하는것도 좋다.

CFDDoSWebViewController 를 부르고, 작업이 끝나면 CoreClient.request를 다시 부르는데 재귀호출이다. 동일한 리퀘스트를 다시 요청하기 위함이다.



여기까지 적용하고 빌드하니 너무나도 잘 동작한다.

그러다 발견한 문제가 있었으니, 앱 내에서 사용하는 채팅 서비스. 채팅 서버의 WebSocket도 CF로 리버스프록시를 걸어놓았었다...

    private func getAPICookieHeader() -> String {
        let header = HTTPCookie.requestHeaderFields(with: self.getAPICookies())
        guard let cookieHeader = header["Cookie"] else { return "" }
        return cookieHeader
    }
        self.websocket?.request.allHTTPHeaderFields = ["token":      UserInfo.token,
                                                       "secret":     UserInfo.tokenSecret,
                                                       "User-Agent": CoreClient.userAgent,
                                                       "Cookie":     self.getAPICookieHeader()]

앱에서 WS 접속을 위해 Starscream 을 사용중인데, 연결 요청시 헤더에서 User-AgentCookie 항목을 CoreClient와 동일하게 하니 정상적으로 동작했다.



여기까지 끝인 것인가! 라고 생각하던 순간, WAS로 부터 내려받는 이미지 리소스에도 WAF가 걸려 UIImage 객체로 가져오는데 실패한 다는 것을 깨달았다.

    func getData(useCache: Bool = true, completion: @escaping ((Data?) -> Void)) {
        let urlString = self.url?.absoluteString ?? "n/a"
        print("<<downloading>> \(urlString)")
        
        if useCache, let cashe = self.findCache() {
            completion(cashe)
        } else {
            var newRequest = self
            newRequest.httpShouldHandleCookies = true
            if let userAgent = UserDefaults.standard.string(forKey: "UserAgent") {
                newRequest.addValue(userAgent, forHTTPHeaderField: "User-Agent")
            }
//            if let url = self.url, let cookies = HTTPCookieStorage.shared.cookies(for: url) {
//                let cookieHeader = HTTPCookie.requestHeaderFields(with: cookies)
//                for item in cookieHeader {
//                    newRequest.addValue(item.value, forHTTPHeaderField: item.key)
//                }
//            }
            
            let task = URLSession.shared.dataTask(with: newRequest) {data, response, error in
                print("\(urlString) -> \((data == nil) ? "nil" : "\(data!.count)") bytes.")
                if useCache, let cashe = data {
                    self.writeCache(cashe)
                }
                DispatchQueue.main.async {
                    completion(data)
                }
            }
            task.resume()
        }
    }

개인적으로 AlamoImage를 사용하지 않고 직접 URLRequest의 extention을 통해 데이터를 부르는 메소드를 만들어서 사용중이며, 내부적으로 샌드박스의 Caches 디렉토리에 1시간 제한적으로 파일캐싱을 하도록 하고 있었다.

기존의 코드에서 요청 헤더에 User-Agent를 추가하니 문제없이 동작하였다.

그 아래의 주석된 코드는, Cookie도 추가해야 될 것이라 생각하여 넣은 코드이나, 실제 HTTPCookieStorage.shared 에 들어있는 쿠키는 URLRequest가 기본적으로 가져가는 것으로 확인 됐다.

댓글
댓글쓰기 폼