众所周知,steam里骗子猖狂。以下页面是骗子私信发给我的一个仿冒的steam钓鱼网站。

s

如果点击 接受礼物 就会跳转至虚假的steam登录界面。

steam

如果毫无防备,通过手机steam客户端扫描右边的二维码进行登录,steam会进行登录异常的警告,一般来说要通过这一步还是很麻烦的。如果强行继续,那么steam账号除了会被劫持api key以外,大概率还会被洗库存,比如一些便宜的卡片和库存里的小件都会被卖出,而不会触发steam手机令牌的验证。

那么骗子是如何制作这些钓鱼网站的?steam对于自身系统的保护究竟做的怎么样?

这些都绕不开对于steam的逆向分析。

开始逆向

抓包观察了一会steam登录界面后,就已经捋清了整个登录站点的逻辑。

在最开始会连接/BeginAuthStatusViaQRCode 获取登录二维码,之后每隔一段时间(5s)就会请求 PollAuthSessionStatus/v1 以更新会话状态,如果当前页面的二维码已经过期,则会刷新。

如果先点击登录按钮,会请求/GetPasswordRSAPublicKey/v1,之后是 /BeginAuthSessionStatusViaCredentials

可以很明显的看到用的是 protobuf 协议。

1.png

查看这个网络请求的来源:

2025-02-07-173039.png

继续追踪这个API的来源。在js文件顶部可以看到这是用webpack打包的:

2025-02-07-174847.png

找到来自Send的方法调用比较可疑:

2025-02-07-191915.png

这里的 r 是一个对象,查看一下它的构造方法:

2025-02-07-195700.png

已经能看到proto的结构了。

构建出用于请求GetPasswordRSAPublicKey/v1的proto:

syntax = "proto3";
package steam;
option go_package = "proto/steam"
message GetPasswordRSAPublicKey_Request {
    string account_name = 1;
}

message GetPasswordRSAPublicKey_Response {
    string public_key_mod = 1;
    string public_key_exp = 2;
    uint64 timestamp = 3;
} 

编译proto:

$ protoc --go_out=. --go-grpc_out=. steam.proto

安装golang的protobuf包:

$ go get google.golang.org/protobuf

发起请求代码,grpc是基于http2协议的,所以直接http get就行:

import (
	"encoding/base64"
	"io"
	"log"
	"net/http"
	"net/url"
	pb "steam/api/steam"

	"google.golang.org/protobuf/proto"
)

func main() {
	api := "/GetPasswordRSAPublicKey/v1"
	p_req := &pb.CAuthentication_GetPasswordRSAPublicKey_Request{
		AccountName: "hello",
	}
	byte_arr, err := proto.Marshal(p_req)
	if err != nil {
		log.Fatal("err marshaling: ", err)
	}
	encoded := base64.StdEncoding.EncodeToString(byte_arr)
	params := url.Values{}
	params.Add("origin", "https://steamcommunity.com")
	params.Add("input_protobuf_encoded", encoded)
	req, _ := http.NewRequest("GET", api, nil)
	req.URL.RawQuery = params.Encode()
	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3")
	c := &http.Client{}
	resp, err := c.Do(req)
	if err != nil {
		log.Fatal("err when http req: ", err)
	}
	defer resp.Body.Close()
	handle_response(resp.Body)

发起grpc请求已经写完了,接下来要处理它的返回。将proto buffers反序列化就能得到steam服务器分发的RSAkey了:

func handle_response(r io.Reader) {
    byte_arr, err := io.ReadAll(r)
    if err != nil {
        log.Fatal("err to read response: ", err)
    }
    resp := &pb.CAuthentication_GetPasswordRSAPublicKey_Response{}
    proto.Unmarshal(byte_arr, resp)
    log.Println(resp)
}

2025-02-08-123703.png

拿到了RSAKey后,才可以开始真正地访问登录接口/BeginAuthSessionStatusViaCredentials

找到了BeginAuthSessionReq的proto定义:

2025-02-08-124620.png

之后找到如何打包请求体:

2025-02-08-130831.png

进行加密的方法就是m.P8, 其中o是密码明文,t是公钥。

const l = m.P8(o, t), c = F.w.Init(A.iP);

m.PB 函数的构造:

2025-02-08-131032.png

开始构建proto文件,需要注意的是 platform_typepersisteance 是两个枚举类型,persistance 字段其实就是remember_login,当后者为true时前者为1,反之亦然。

message BeginAuthSessionViaCredentials_Request {
    string device_friendly_name = 1;
    string account_name = 2;
    string encrypted_password = 3;
    string encryption_timestamp = 4;
    bool remember_login = 5;
    platformType platform_type = 6;
    persistence persistence = 7;
    string website_id = 8;
    DeviceDetails device_details = 9;
    string guard_data = 10;
    int32 language = 11;
    int32 app_level = 12;
}

而里面的 DeviceDetails 是一个结构体,device_friendly_name 其实就是UA,platform_type 在 Windows 下是2。

message DeviceDetails {
    string device_friendly_name = 1;
    string platform_type = 2;
    string os_type = 3;
    string gaming_device_type = 4;
    string client_count = 5;
    string machine_id = 6;
    string app_type = 7;
}

而对于QR code的请求api是

/BeginAuthSessionStatusViaQR

2025-04-19-201356.png

message CAuthentication_DeviceDetails {
    string device_friendly_name = 1;
    string platform_type = 2;
    string os_type = 3;
    string gaming_device_type = 4;
    string client_count = 5;
    string machine_id = 6;
    string app_type = 7;
}

message CAuthentication_BeginAuthSessionViaQR_Request {
    string device_friendly_name = 1;
    string platform_type = 2;
    CAuthentication_DeviceDetails device_details = 3;
    string website_id = 4;
}

message CAuthentication_BeginAuthSessionViaQR_Response {
	string client_id = 1;
	string challenge_url = 2;
	bytes request_id = 3;
	float interval = 4;
	repeated AllowedConfirmation allowed_confirmations = 5;
	int32 version = 6;
w}

然而,QRCode的返回是json格式的:

2025-04-20-021227.png

拿到了challenge_url,至此我的目标已经完成了。后续对于使用密码登录的令牌验证过程,以及登录成功后的回调等以后再看看吧。

2025-02-07-184151.png (the place send grpc req)

比较有意思的是steam官网里留存了一个没有经过混淆加密的login.js,就连注释也在,应该是很很久以前留下来的代码。

在2019年的时候,这些api还有效,Github上的这个脚本就是逆向了这些api。但时至今日,这些API已经被弃用了。

目前能看到这个 LoginManager 是会初始化的,但里面的方法却都不会被调用。

$J.post( this.m_strBaseURL + 'getrsakey/', this.GetParameters( { username: username } ) )
		.done( $J.proxy( this.OnRSAKeyResponse, this ) )
		.fail( function () {
			ShowAlertDialog( '错误', '与 Steam 服务器通信时出现问题。请稍后重试。' );
			$J('#login_btn_signin').show();
			$J('#login_btn_wait').hide();
			_this.m_bLoginInFlight = false;
		});