golang

Golang: Websocket masking과 cache poisoning

주먹불끈 2021. 11. 22. 09:44

Photo by Christopher Robin Ebbinghaus on Unsplash

 

Websocket protocol 보면 mask 여부를 표시하는 flag bit와 masking 적용시 사용할 maksing-key값 들어가있다. 찾아보니 cache poisoning 방지해준다 한다. cache poisoning이 뭘까? 

 

cache poisoning이란 - 초간단 버전

cache poisoning까지 실제로 해보고, websocket message masking/non-masking 했을때의 차이까지 들여다보고 싶어졌지만 있었지만 토끼굴로 들어가면 안된다 싶어 최소한의 이해만 하고 넘어가도록 한다.

* 토끼굴(rabbit hole): a situation in which you become so interested in a subject or an activity that you cannot stop trying to find out about it or doing it

 

정상적인 상황

User website HTTP통신을 한다면 중간에 infrastructure 또는 proxy server 거치는 경우가 많은데, 만약 같은 request 많다면 매번 website까지 필요가 없이 지난 website response cache 저장해 두었다가 바로 회신할 있을 것이다. 이것이 아래 그림의 세 번의 request/response 쌍의 상황이다. 노란색 user request website까지 가서 response 받아오지만, cache server 이를 저장해 두었다가 파란색, 보라색 user request 바로 회신해준다.

 

참고 링크 및 이미지 출처: https://portswigger.net/research/web-cache-entanglement

 

 

그러면 캐싱은 어떻게 할까?

동일한 request라는 인지할 있을 만큼의 정보를 request에서 추출해내서 key 삼는다 이걸 cache key라고 한다.

그리고 해당 request 대해 website 회신한 response 전체를 value 저장하는 것이다이제 request에서 추출한 cache key 저장된 value 이미 있으면(== 캐싱되어 있으면) website 요청할 필요 없이 바로 response 해줄 있다.

 

request

GET /research?x=1 HTTP/1.1
Host: portswigger.net
X-Forwarded-Host: attacker.net
User-Agent: Firefox/57.0
Cookie: language=en;
request에서 추출한 cache key Cache key: https|GET|portswigger.net|/research?x=1

공격자의 cache poisoning

request에서 cache key 사용하지 않는 녀석들을 unkeyed components라고 한다. 예에서 X-Forwarded-Host, User-Agent, Cookie 같은 것들이다. 공격자는 이러한 곳에 악성코드를 심어서 website response 할때에 "아무 생각없이" 포함해서 회신하게 만든다. 이렇게 "" 머금은 cache 중간의 proxy server infrastructure 가지고 있다가 정상적인 request 대해서 회신을 해줘버리는 거다. 그림의 아래 세 번의 request/response 상황이다.

 

TL;DR. websocket masking cache poisoning 어떻게 막는가?

매번 클라이언트가 메시지를 보낼때마다 랜덤하게 생성한 masking key 메시지를 XOR 해준다. 이렇게 해주면 websocket message HTTP request(또는 response) 인식해버리는 똑똑하지 못한 proxy server, infrastructure라도 똑같은 websocket message 모두 다른 HTTP request(== 다른 cache key) 인식해버려서 캐싱하지 않는 것이다. 복잡하고 어려운 암호화가 필요없는 이유도 여기에 있다.

Websocket protocol에서의 masking

이전 포스팅 https://jusths.tistory.com/249 에서도 소개한 websocket protocol에서의 maksing 복습해보자

 

1. MASK bit 1 설정해서 masking 한다는 것을 알리고

2. Masking-Key 32bit 추가해둔다. 이때 전송하는 Payload Data를 Masking-Key로 XOR 해준다.

3. 그러면 서버는 Payload Data Masking-Key XOR해줘서 원본을 복구해주면 되는 것이다.

 

gorilla/websocket에서는 어떻게 구현되어 있지?

gorilla/websocket GitHub: https://github.com/gorilla/websocket

websocket protocol 구현한 Golang package gorilla/websocket에서는 이를 어떻게 구현해두었는지 살펴보았다. 

클라이언트에서 마스킹을 해서 쓰기 - 무조건 마스킹한다.

앞서 살펴보았듯 cache poisoning을 막으려면 캐시 서버가 같은 request라고만 생각지 않으면 된다. 그래서 클라이언트가 서버로 보내는 메시지만 masking 을 해주면 된다. gorilla/websocket은 클라이언트에서 보내는 메시지만 무조건 masking 하도록 구현이 되어있다.

 

Websocket으로 연결이 된 다음 메시지를 전달하려면 WriteMessage 메서드를 사용하면 되는데, 클라이언트는 NextWriter 메서드로 생성한 *messageWriter의 Write 메서드를 사용한다

 

https://github.com/gorilla/websocket/blob/e8629af678b7fe13f35dff5e197de93b4148a909/conn.go#L749WriteMessage

// WriteMessage is a helper method for getting a writer using NextWriter,
// writing the message and closing the writer.
func (c *Conn) WriteMessage(messageType int, data []byte) error {

	if c.isServer && (c.newCompressionWriter == nil || !c.enableWriteCompression) {
		// Fast path with no allocations and single frame.

		var mw messageWriter
		if err := c.beginMessage(&mw, messageType); err != nil {
			return err
		}
		n := copy(c.writeBuf[mw.pos:], data)
		mw.pos += n
		data = data[n:]
		return mw.flushFrame(true, data)
	}

	w, err := c.NextWriter(messageType)
	if err != nil {
		return err
	}
	if _, err = w.Write(data); err != nil {
		return err
	}
	return w.Close()
}

Write 메서드에서는 다시 flushFrame 메서드를 직접, 또는 ncopy 메서드를 거쳐서 사용하는데 바로 flushFrame 메서드에서 masking 작업을 한다

https://github.com/gorilla/websocket/blob/e8629af678b7fe13f35dff5e197de93b4148a909/conn.go#L650

func (w *messageWriter) Write(p []byte) (int, error) {
	if w.err != nil {
		return 0, w.err
	}

	if len(p) > 2*len(w.c.writeBuf) && w.c.isServer {
		// Don't buffer large messages.
		err := w.flushFrame(false, p)
		if err != nil {
			return 0, err
		}
		return len(p), nil
	}

	nn := len(p)
	for len(p) > 0 {
		n, err := w.ncopy(len(p))
		if err != nil {
			return 0, err
		}
		copy(w.c.writeBuf[w.pos:], p[:n])
		w.pos += n
		p = p[n:]
	}
	return nn, nil
}

flushFrame 메서드에서 websocket protocol에 맞게 프레임을 만들어내는 것이다. 마스크 관련한 부분만 가져와 본다

 

서버쪽 연결이 아니면, 즉, 클라이언트쪽에서 보내는 메시지라면 maskBit을 적용하는 것이다. 매 번 flushFrame 메서드를 호출할 때 마다 newMaskKey로 key를 생성하는 것도 볼 수 있다. 

// Frame header byte 1 bits from Section 5.2 of RFC 6455
const maskBit = 1 << 7

// flushFrame writes buffered data and extra as a frame to the network. The
// final argument indicates that this is the last frame in the message.
func (w *messageWriter) flushFrame(final bool, extra []byte) error {

    // omit codes
    b1 := byte(0)
    if !c.isServer {
        b1 |= maskBit
    }

    // omit codes
    if !c.isServer {
        key := newMaskKey()
        copy(c.writeBuf[maxFrameHeaderSize-4:], key[:])
        maskBytes(key, 0, c.writeBuf[maxFrameHeaderSize:w.pos])
        if len(extra) > 0 {
            return w.endMessage(c.writeFatal(errors.New("websocket: internal error, extra used in client mode")))
        }
    }
    // omit codes
}

실제 masking을 해주는 함수는 maskBytes이다. 패키지에서는 safe모드와, 성능을 향상시킨 unsafe 모드를 제공한다. 링크를 아래에 공유한다

 

safe mode: https://github.com/gorilla/websocket/blob/e8629af678b7fe13f35dff5e197de93b4148a909/mask_safe.go#L9

unsafe mode: https://github.com/gorilla/websocket/blob/e8629af678b7fe13f35dff5e197de93b4148a909/mask.go#L13

 

서버에서 읽기 

여기까지 이해하면 어떻게 처리할지는 충분히 예측이 가능하다.

 

1. ReadMessage 메서드의 주체가 서버인지를 확인해서

2. 서버라면 받은 프레임의 MASK bit를 확인하고, Masking-Key까지 알아낸다

3. 그래서 maskBytes 함수로 Payload Data를 다시 한 번 XOR 해준다. XOR을 두 번 해주면 원래 data가 된다. 

 

코드를 보자. ReadMessage 메서드는 NextReader 메서드를 호출하는데 결국 실제 처리는 advanceFrame 메서드가 한다.

아래에 핵심 부분만 추려보았다.

 

https://github.com/gorilla/websocket/blob/e8629af678b7fe13f35dff5e197de93b4148a909/conn.go#L787

func (c *Conn) advanceFrame() (int, error) {
	
	// omit codes
	// 4. Handle frame masking.

	if mask != c.isServer {
		return noFrame, c.handleProtocolError("incorrect mask flag")
	}

	if mask {
		c.readMaskPos = 0
		p, err := c.read(len(c.readMaskKey))
		if err != nil {
			return noFrame, err
		}
		copy(c.readMaskKey[:], p)
	}

	// omit codes
	// 6. Read control frame payload.

	var payload []byte
	if c.readRemaining > 0 {
		payload, err = c.read(int(c.readRemaining))
		c.setReadRemaining(0)
		if err != nil {
			return noFrame, err
		}
		if c.isServer {
			maskBytes(c.readMaskKey, 0, payload)
		}
	}
	// omit codes
}
반응형