IMPORTANT NOTICE: This article is written for educational purposes only. It is unofficial and NOT intended to cause harm to Supercell or anyone in any way.
On Tuesday January 2022, I have released an offline Brawl Stars client which was kinda of a surprise for most people since I have not mentioned working on it. (quick remark: I am not the first to make one, as I got inspired and motivated by what Mr. Vitalik have made, the first offline client for Brawl Stars, which was on v34)
When I first did it, I did not have that much skills as today. I have made a server inside the client itself to emulate the connectivity, which made me recently wonder if I could make it without the need of a server, and I did. Now and more than 2 years have passed over the release of my v26 offline client, I have chosen to share the steps to do it.
In this article, I will go with v30.x (arm64) for android.
first of all, we need to bypass the protections, obviously
v30 uses arxan, which provided back then various protections, including anti-intercepting for native functions, strings encryption, and some pretty good obfuscation, with probably levels for it? since some functions were... let's just say a bit (way more) harder than others to understand or decompile.
NOTE: I will not be providing the full code, but I will be providing the steps and the main idea behind it. I will also not be sharing some private tools that were used to accomplish some results, but you can dig your way without problems without them.
I will not be going to explain too much about bypassing the protections since it is not the main idea of the article, but I will cover them up pretty quick.
The first one we will bypass is cgi (createGameInstance).
This is the easiest one among all. It is just a pretty big obfuscated function, that it's job is literally calling a single function in it: GameMain::GameMain and set it to GameMain::sm_pInstance or, GameMain::getInstance which does the same thing.
// some functions and variables were renamed for readability
__int64 createGameInstance()
{
// ... ton of shit code that we don't need
for ( i16 = 1; ; i16 = 2 )
{
v684 = i16;
if ( i16 + 1 >= 3 )
break;
v682 = v674;
}
// more shit code...
v595 = v115 + v106;
v592 = v106;
some_baddata_variable = 0x10000;
return GameMain::getInstance();
}
The only good code in that function is the call of GameMain::getInstance, so we can simply patch it with Interceptor.replace:
Interceptor.replace(base.add(createGameMain),
base.add(GameMain_getInstance));
As I mentioned earlier though, this article is not about protections, so I won't be explaining how to bypass the rest, like GameMain::GameMain which also includes a small signature check, so let's hop on into what we waited for!
(remark: v36 is different than v30 with the messaging part, so I might give some functions custom names just to make them understandable)
Now, we need to patch ServerConnection::update
// functions were renamed for readability.
// They might not be the same in the actual code.
__int64 __fastcall ServerConnection::update(__int64 result, long double a2)
{
// some code...
LABEL_9:
v7 = Messaging::hasConnectFailed(*(_QWORD *)(result + 8));
if ( (v7 & 1) == 0 )
{
result = Messaging::isConnected(*(_QWORD *)(v3 + 8));
if ( (result & 1) != 0 )
{
v15 = *(_QWORD *)(v3 + 8);
*(_DWORD *)v3 = 9;
// more code...
}
}
// more code...
}
__int64 __fastcall Messaging::hasConnectFailed(__int64 a1)
{
return *(unsigned __int8 *)(a1 + 464);
}
bool __fastcall Messaging::isConnected(__int64 a1)
{
return *(_DWORD *)(a1 + 588) > 1u;
}
Our target is *(unsigned __int8 *)(a1 + 464) and *(_DWORD *)(a1 + 588)
We will be hooking ServerConnection::update to set them.
Our code will look like this:
Interceptor.attach(base.add(ServerConnection_update), {
onEnter(args) {
args[0].add(8).readPointer().add(464).writeU8(0);
args[0].add(8).readPointer().add(588).writeInt(2);
},
});
And that's it! You have made Brawl-Stars offline!
but wait... how do we reach menu? or basically do anything a server would have done?
Although we did patch the connectivity, we have to "emulate" the messages in the client
First, we need to do some patches, ex for Messaging::send, Messaging::recv, MessageManager::sendMessage, etc...
const MessageManager_receiveMessage = new NativeFunction(base.add(...), "bool",
["pointer", "pointer"]);
const MessageManager_getInstance = function () {
// this is the real address. I can't avoid putting it here, lol.
return base.add(0xdeaed0).readPointer();
};
const operator_new = new NativeFunction(base.add(...), "pointer", ["int"]);
const createMessageByType = new NativeFunction(base.add(...), "pointer",
["int", "int"]);
Interceptor.replace(
MessageManager_receiveMessage,
new NativeCallback(
function (messageManager, message) {
MessageManager_receiveMessage(messageManager, message);
return 1;
},
"uint8",
["pointer", "pointer"]
)
);
// A simple patch for the recv function, since it does have a protection.
// you can find it by looking for "gum-js-loop"
Interceptor.replace(base.add(0x202044),
new NativeCallback(function (a1, a2, a3) {
return a2; // a2 is the size of the data to receive
},
"int",
["pointer", "int", "int"]
));
Interceptor.attach(base.add(MessageManager_sendMessage), {
onEnter(args) {
// ensure that state is always 5
args[0].add(64).readPointer().add(588).writeInt(5);
},
});
// and finally...
Interceptor.replace(
base.add(Messaging_send),
new NativeCallback(
function (self, message) {
// our logic here
PiranhaMessage.destroyMessage(message);
return 0;
},
"int",
["pointer", "pointer"]
)
);
Before writing our logic, you need to find some offsets and make the following class (and implement it's methods):
class PiranhaMessage {
static getMessageType(message) {
// simply calls PiranhaMessage::getMessageType
// it is in the message vtable.
}
static getMessageLength(message) {
// returns the length by calling getEncodingLength.
// also in the vtable.
}
static getMessageBuffer(message) {
// returns a pointer for the buffer
}
static destroyMessage(message) {
// calls the message destructors using it's vtable
}
}
// we also need the following function:
function sendOfflineMessage(id, payload) {
let version = id == 20104 ? 1 : 0;
let message = createMessageByType(0, id);
message.add(8 /* the offset may be different in the version
you're using, so better checking it first. */).writeInt(version);
message
.add(2 * 8)
.add(8 + 16)
.writeInt(payload.length); // same applies to the above note.
if (payload.length > 0) {
let payload_ptr = operator_new(payload.length).writeByteArray(payload);
message
.add(2 * 8)
.add(8 + 24)
.writePointer(payload_ptr); // and the same... lol
}
let decode = new NativeFunction(
message
.readPointer()
.add(3 * 8) // 3 * pointerSize, at least at the time of writing this
// and specifically in v30.x
.readPointer(),
"void",
["pointer"]
);
decode(message);
MessageManager_receiveMessage(MessageManager_getInstance(), message);
}
/*
now there is a thing: createMessageByType expects a message factory argument
we did set it above to 0 (NULL),
but it won't really create the message as there is a check.
you can just allocate the factory by yourself and pass it to the function.
or do as I did, patch that check.
*/
Memory.patchCode(base.add(0x43be88), Process.pageSize, (code) => {
const pcWriter = new Arm64Writer(code);
pcWriter.putNop();
pcWriter.putBranchAddress(base.add(0x43bed8));
pcWriter.flush();
});
We are close to add our final touches!
/*
in v30.x, createMessageByType does not have a check for LoginOkMessage,
thus it does not create it.
I will create it using it's constructor address
lower versions do have it in createMessageByType though.
So if you are using a lower version, you can skip this step
*/
const LoginOkMessage_ctor = new NativeFunction(base.add(...), "pointer",
["pointer"]);
Interceptor.replace(
base.add(Messaging_send),
new NativeCallback(
function (self, message) {
let type = PiranhaMessage.getMessageType(message);
/*
since we are in offline, we do not really need to deal with encryption
so we send LoginOkMessage and OwnHomeDataMessage without waiting for
LoginMessage, as it is not needed.
(we update the state to 5 in the send function)
*/
if (type == 10100) {
let loginok = operator_new(0x170);
LoginOkMessage_ctor(loginok);
let hl = operator_new(8); // LogicLong, for player id
hl.writeInt(0);
hl.add(4).writeInt(1);
loginok.add(136).writePointer(hl);
hl = operator_new(8);
hl.writeInt(0);
hl.add(4).writeInt(1);
loginok.add(144).writePointer(hl); // they are both destroyed,
// by LoginOkMessage::destruct
MessageManager_receiveMessage(MessageManager_getInstance(), loginok);
sendOwnHomeDataMessage();
} // handle other types if you want to...
PiranhaMessage.destroyMessage(message);
return 0;
},
"int",
["pointer", "pointer"]
)
);
function sendOwnHomeDataMessage() {
// sending OwnHomeDataMessage by manually constructing it and setting fields,
// would take a lot of time,
// so I will just create it's payload and decode it as a server would do.
let stream = new ByteStream();
stream.writeVInt(0);
stream.writeVInt(0);
stream.writeVInt(0); // player trophies
stream.writeVInt(0); // player highest trophies
stream.writeVInt(0);
// rest of the code ...
sendOfflineMessage(24101, stream.bytes());
}
Now one more thing... Messaging::send will be called way too much that might cause some noticeable performance drop.
Memory.patchCode(base.add(MessageManager_sendKeepAliveMessage), Process.pageSize,
(code) => {
const pcWriter = new Arm64Writer(code);
pcWriter.putRet();
pcWriter.flush();
});
And that's it! You have made Brawl Stars offline!
Now you can play it without the need of a server!
(note: you can implement other messages by constructing them as we did for LoginMessage or encoding them as we did for OwnHomeDataMessage)