IP伪装解密:后端服务究竟如何找到你?

394 阅读8分钟

前言

最近在做业务时,接到了一个小需求: 后端服务记录下请求客户端的Ip地址,方便后续的问题定位和追踪。虽然请求的客户端有后台web服务,移动app和设备,这里想追踪的其实是设备。

在这种图中,工程网关和子设备-电表,子设备-开关属于同一个Zigbee自组网中,而工程网关与云端服务以MQTT协议进行交互。客户通过后台网页服务和移动App对电表和开关进行控制指令的下发,但是偶尔会发生子设备没发生响应的情况。项目经理认为是我没有将指令投递到网关上。

此时的我汗流浃背,满头大汗。问题的关键是找到关键的问题

事情到了这里,我作为被告,我需要证明控制指令已经下发到了网关上。立马我问嵌入式同事是否有方法可以看到网关的运行日志。嵌入式同学说,你拿到设备的真实Ip地址,直接通过ssh命令即可登录到网关中看到网关的运行日志了。

此刻,我觉得离我沉冤昭雪的日子马上到来了。

愚昧之山

作为多年的CRUD工程师,我熟练掌握了Restful接口开发网络Socket编程。我写下了验证我清白的2行代码:

HTTP接口:

@RequestMapping("/")
@ResponseBody
public void requestIn(@PathVariable String entry,
                      HttpServletRequest httpServletRequest,
                      HttpServletResponse httpServletResponse) {

        // 获取客户端 IP 地址
        String clientIp = request.getRemoteAddr();

}

TCP网络编程逻辑:

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        // 获取客户端 IP 地址
        log.info("address: {}", ctx.channel.remoteAddress();
        ......
    }

当我急切的将其部署上去时,发现上述2个接口中获取的IP地址都是: 192.168.1.107

难道那里出现问题了么。我恍然想起,我的后端服务都是经过代理过的,这个IP是envoy那台机器的地址。(备注: Envoy是一个类似Nginx的服务代理,但是可动态配置,性能很高且灵活性,在云原生时代的指定CNCF代理服务)

绝望之谷

此时的我拿出服务的部署架构图,我仔细端详:

云原生Envoy代理服务.jpg

在常见的代理配置中,TCP和HTTP的代理配置也是有所不同的,这里需要普及下知识。

OSI七层模型

要聊几层代理,需要先看一下网络分层,标准的七层网络分层,也就是OSI七层模型。TCP/IP五层模型和TCP/IP四层模型是从OSI七层优化而来。

OSI七层模型

从下往上看,第四层为传输层、第七层为应用层。再来看看每层对应的常见协议:

OSI及协议

四层对应的是TCP/UDP协议,也就常说的IP+端口。七层已经是非常具体的应用层协议了。因此,所谓四层就是基于IP+端口的负载均衡;七层就是基于URL等应用层信息的负载均衡。

四层代理

四层代理主要工作于OSI模型中的传输层,传输层主要处理消息的传递,而不管消息的内容。TCP就是常见的四层协议。

四层负载均衡只针对由上游服务发送和接收的网络包,而并不检查包内的具体内容是什么。四层负载均衡可以通过检查TCP流中的前几个包,从而决定是否限制路由。

因此,四层负载均衡的核心就是IP+端口层面的负载均衡,不涉及具体的报文内容。

七层代理

七层代理主要工作于OSI模型的应用层,应用层主要用来处理消息内容的。比如,HTTP便是常见的七层协议。

七层负载均衡服务器起到了反向代理的作用,Client端要先与七层负载均衡设备三次握手建立TCP连接,把要访问的报文信息发送给七层负载均衡。

七层负载均衡器基于消息中内容( 比如URL或者cookie中的信息 )来做出负载均衡的决定。之后,七层负载均衡器建立一个新的TCP连接来选择上游服务并向这个服务发出请求。

此时我的心凉半截,如此复杂啊!

iShot_2024-01-15_15.39.01.png

开悟之坡

经过 3 分钟的自我内耗后,并喝了一杯热水♨️后,拾起书📚来补充下知识。

Web服务七层代理处理

在http/https的协议中,我们可以通过 X-Forwarded-For 从 Header 信息中获取到离服务端最近的 client 端的 IP 地址, 如果请求经过了多级代理且每级代理都开启此特性, 就可以获得真实有效的用户 IP。

Envoy配置:

static_resources:
  listeners:
    # \u4e0d\u5e26psk\u8ba4\u8bc1\u7684\u8bbe\u5907\u7684atop\u5165\u53e3
    - name: listener_http_8888
      address:
        socket_address:
          address: 0.0.0.0
          port_value: 8888
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: ingress_http_8888
                access_log:
                  ......
                http_filters:
                  - name: envoy.filters.http.router      
                use_remote_address: true
                xff_num_trusted_hops: 1
                route_config:
                  ......

这里关键的配置主要有 2 行:

  • use_remote_address: x-forwarded-for (XFF) 是一个标准的代理标头,它表示请求在从客户端到服务器的路上经过的 IP 地址。 在代理请求之前,兼容代理会将最近客户端的 IP 地址附加到 XFF 列表中。

    仅在 use_remote_address HTTP 连接管理选项设置为 true 时,Envoy 才会追加到 XFF 。 这意味着如果 use_remote_address 为 false(这是默认值),则连接管理器将以不修改 XFF 的透明模式运行。

  • xff_num_trusted_hops: 如果 use_remote_address 为 true 并且 xff_num_trusted_hops 设置为大于零的值 N,则可信客户端地址是 XFF 右端的第 N 个地址。 (如果 XFF 包含的地址少于 N 个,Envoy 就会使用直接下行连接的源地址作为可信客户端地址。)

后端服务获取IP地址:

@RequestMapping("/")
@ResponseBody
public void requestIn(@PathVariable String entry,
                      HttpServletRequest httpServletRequest,
                      HttpServletResponse httpServletResponse) {

        // 获取 X-Forwarded-For 头部值
        String xForwardedForHeader = httpServletRequest.getHeader("X-Forwarded-For");
        // 提取真实客户端 IP 地址
        String clientIpAddress = extractClientIpAddress(xForwardedForHeader);
        log.info("Client IP Address:{}", clientIpAddress);

}

TCP层代理处理

使用Proxy Protocol: proxy protocol 最早由 HAproxy 发明实现. 是一种类似 X-Forwarded-For 的基于应用层实现的方式. 由于是基于应用层, 所以需要客户端和服务器端同时支持此协议。

其如何工作的呢?proxy protocol 支持 v1 和 v2 两个版本. v1 版本以明文的字符串发送数据, v2 版本以二进制格式发送. 简单而言, proxy protocol 实现主要是在建立 TCP 连接后, 在发送应用数据之前先将用户的 IP 信息发送到服务端. 我们可以简单理解为 TCP 三次握手完成后由 proxy protocol 的客户端立即将用户的 IP 信息发送过来。

Envoy配置:

    - name: mqtt_service
      connect_timeout: 30s
      per_connection_buffer_limit_bytes: 32768
      health_checks:
        ......
      type: STATIC
      lb_policy: ROUND_ROBIN
      transport_socket:
        name: envoy.transport_sockets.upstream_proxy_protocol
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.transport_sockets.proxy_protocol.v3.ProxyProtocolUpstreamTransport
          config:
            version: V2  
          transport_socket:
            name: envoy.transport_sockets.raw_buffer
      load_assignment:
        cluster_name: mqtt_service
        endpoints:
          ......

这里我使用的是V2版本的Proxy Protocol协议。

后端服务处理:

@Override
    protected void initChannel(SocketChannel ch) {
        ChannelPipeline pipeline = ch.pipeline();

        if (context.isProxyEnabled()) {
            pipeline.addLast("proxy", new HAProxyMessageDecoder());
            pipeline.addLast("ipFilter", new ProxyIpFilter(context));
        } else {
            pipeline.addLast("ipFilter", new IpFilter(context));
        }
        ......
    }

在这段代码中,可以发现我是否经过代理做成配置项来分别处理了。为什么要这么做呢?

因为在MQTT协议中,客户端发起的第一个请求会是CONNECT报文。而经过Envoy代理后的服务,Envoy会在建立连接的时候发送且只发送一次Proxy Protocol报文。如果 2 者进行混用。会发现不代理的情况下,MQTT客户端连不上的情况。

此时,有的同学奇怪HAProxyMessageDecoder这个解码器放在最上面,对于正常请求有影响没?那让我们看下其逻辑:

    @Override
    protected final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        // determine the specification version
        if (version == -1) {
            if ((version = findVersion(in)) == -1) {
                return;
            }
        }

        ByteBuf decoded;

        if (version == 1) {
            decoded = decodeLine(ctx, in);
        } else {
            decoded = decodeStruct(ctx, in);
        }

        if (decoded != null) {
            finished = true;
            try {
                if (version == 1) {
                    out.add(HAProxyMessage.decodeHeader(decoded.toString(CharsetUtil.US_ASCII)));
                } else {
                    out.add(HAProxyMessage.decodeHeader(decoded));
                }
            } catch (HAProxyProtocolException e) {
                fail(ctx, null, e);
            }
        }
    }

留意注释里强调的:finished变量,接着往下看:

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        super.channelRead(ctx, msg);
        if (finished) {
            ctx.pipeline().remove(this);
        }
    }

解码成功后,后面就将它移除了,所以不用担心后面读取正常请求数据的时候会被这个decoder影响到。

效果演示

 Received msg: HAProxyMessage(protocolVersion: V2, command: PROXY, proxiedProtocol: TCP4, sourceAddress: 192.168.1.106, destinationAddress: 172.18.0.4, sourcePort: 60408, destinationPort: 5883, tlvs: [])
Received msg: HAProxyMessage(protocolVersion: V2, command: PROXY, proxiedProtocol: TCP4, sourceAddress: 192.168.1.109, destinationAddress: 172.18.0.4, sourcePort: 6106, destinationPort: 8883, tlvs: [])

拿到这个网关的IP地址后,我进到这个网关里查看其运行日志。和嵌入式进行疯狂的battle。最后证明我的程序是没问题的,是子设备信号指令查导致的。

开心一笑!深藏功与名。希望大家后续可以通过这个实战案例,学习如何获取这狡猾的IP地址。

iShot_2024-01-15_16.22.37.png