Injecting a theos Tweak to Modify a Game's Avatar Upload and Serve an Animated GIF

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.


Injecting a theos Tweak to Modify a Game’s Avatar Upload and Serve an Animated GIF

Intro

This one’s been on my list for a while — partly to record the code for myself, partly to share it.

Requirement and final result

The game doesn’t support uploading animated GIFs: before upload, the client snapshots the image into a still frame. By injecting code I got it to upload the animated image instead.

Environment and tools

I was on macOS 10.10 before, which caps out at Xcode 7 — and Xcode 7 has no on-device debugging. So I upgraded to macOS Catalina 10.15.4 with the latest Xcode, but that version is slow and breaks a lot of software; I planned to downgrade later.

Environment Version
OS macOS Catalina 10.15.4 (a bit too new — a lot of tools broke, so I planned to downgrade)
Device iPhone 7, iOS 11 (jailbroken)
theos on macOS latest
Transfer tool / SSH —
Xcode 11.5
On-device FLEXible (UI inspection + packet capture), or Charles —
Hopper Disassembler on macOS —

theos is the tool for writing tweak code and packaging it into a deb. FLEXible is an on-device debugging tool: it inspects an app’s UI hierarchy, shows the methods and variables in class-dumped headers, and can capture packets.

The approach

Idea A: find the image-upload POST request, then change the image URL parameter. Dead end — the client signs all parameters and sends the signature to the backend; changing any parameter breaks signature validation.

Idea B: change the local image path right when the crop window saves. Maybe workable, but I wanted something simpler.

Idea C: inject into the code that assembles the parameter dictionary right before upload, and forcibly swap the image parameter’s URL. This doesn’t disturb the signature generation logic.

Idea D: disassemble and reverse the signing algorithm. Hard and time-consuming, but if you crack it you could forge data on essentially every request via a man-in-the-middle. I went with Idea C — it’s the simpler path.

Tracing the image-upload request

Use FLEXible to get a rough idea of the controllers and methods in the view hierarchy:

I didn’t really need the hierarchy — I just needed the upload to trigger a capture. Open FLEXible’s menu, choose Network History, tap Settings, and enable packet capture. Go back to the game, upload any image, then reopen Network to see the capture list:

The two suspicious POST requests are the first two: upload.qiniup.com and update_user_info. Open them for details:

The image doesn’t fit in the capture, so I copied it as curl:

curl -v -X POST ‘https://upload.qiniup.com’ -H ‘User-Agent: QiniuObject-C/7.3.2 (iPhone; iOS 11.2.6; ABE768DC-FA02-4CAB-9DF8-2720B0E7C890; GcvQmcsBpX-WqRIW_YTKKXua3PbSXh831RS_u2NW)’ -H ‘Content-Type: multipart/form-data; boundary=werghnvt54wef654rjuhgb56trtg34tweuyrgf’ -H ‘Content-Length: 27824’ -H ‘Cookie:’ [TOO MUCH DATA TO INCLUDE]

This request uploads the image stream. The boundary can’t be written out fully — FLEXible doesn’t display image stream data — and the Cookie is truncated too; it carries the user’s sid and similar. This request also depends on a token from the third request in the list: you fetch the token first, then upload. I’ll show that in a shell script below.

On success the upload returns the image’s key and hash — note this only uploads the image to the hosting bucket; it isn’t linked to the game avatar yet.

{
  "key" : "sa/fc6861905d20200613100399.jpg",
  "hash" : "FifEYajsNl7aqU88gsOYSyewk199"
}

That key is what the next request uses as its headimgurl parameter.

The other POST request (sensitive parameters replaced with example):

curl -v -X POST ‘https://example.com/user\_api/update\_user\_info’ -H ‘market: apple’ -H ‘Content-Type: application/x-www-form-urlencoded’ -H ‘ExampleVersion: 4.3.31’ -H ‘ExampleUserID: example-5631-4446-example-09da14d65cb9’ -H ‘ExampleProduct: example’ -H ‘User-Agent: example/4.1.31 (iPhone; iOS 11.2.6; Scale/3.00)’ -H ‘Accept-Language: zh-Hans-CN;q=1’ -H ‘nonce: 1592013691’ -H ‘Cookie:’ -d ‘build_tag=4531.0&device_id=example-A6FA-4A74-8CA3-example-1779-0000008790C7E566&device_name=iPhone%207%20Plus&device_system_platform=iPhone9%2C2&headimgurl=https://example.com/sa/fc6861905d20200613100399.jpg&idfa=0D79361D-C19F-4A77-84Bd-D725AA96B4D8&market=apple&os\_version=11.2&platform=1&sid=example&example\_sign=%2B70m/3s/1314dgNQ641mE9YmN6c%3D&uid=example&version=4.1.31’

This POST updates the avatar, and the key parameter is headimgurl. You can’t just change the URL in the request, because example_sign is computed over all request parameters — the backend rejects it with a signature failure unless you recompute the sign with the algorithm.

Uploading my own GIF to the bucket with a shell script

The code I want to inject is the method that assembles update_user_info — I just swap the headimgurl value at assembly time. But first I need to upload my own image to the bucket, not through the game’s upload flow, but with my own shell script that simulates the POST and returns the final URL.

#! bin/shell#! bin/shell

# content, keyword to search for
getContentByKeyword() {
	content=$1
	keyword=$2
	echo $content | awk -F \"$keyword\"\:\" '{ print $2 }' | awk -F \" '{print $1}'
}
 
getHeaderByKeyword() {
	content=$1
	keyword=$2
	echo $content | grep $keyword | awk -F $keyword\:\  '{ print $2 }' | awk -F \  '{print $1}'
}

content=`curl -v -X POST 'https://example.com/config/get_upload_token' -H 'market: apple' -H 'Content-Type: application/x-www-form-urlencoded' -H 'ExampleVersion: 4.1.26' -H 'ExampleUserID: example' -H 'ExampleProduct: example' -H 'User-Agent: example/4.3.26 (iPhone; iOS 11.2.6; Scale/3.00)' -H 'Accept-Language: zh-Hans-CN;q=1' -H 'nonce: 1589783647' -H 'Cookie:' -d 'bucket=example&build_tag=example.0&device_id=example-A4FA-4A64-example-example-1779-example&device_name=iPhone%207%20Plus&device_system_platform=iPhone9%2C2&idfa=example-C18F-4A77-84BC-example&market=apple&os_version=11.2&platform=1&sid=example&example_sign=8HX%example%3D&type=2&uid=example-5495-439f-example-example&version=4.1.26'`
token=`getContentByKeyword "$content" "token"`
echo $token >> test2.txt


uploadResponse=`curl  -v -i https://upload.qbox.me  -F "token=$token"     -H 'User-Agent: QiniuObject-C/7.1.0 (iPhone; iOS 11.2.6; example-FA02-4CAB-example-example)' -H 'Content-Type: multipart/form-data' -H 'Accept-Language: zh-cn' -H 'Accept-Encoding: br, gzip, deflate' -H 'Cookie:'  -H 'Accept: */*' -F "file=@/Users/mac/Desktop/test4.gif"`
nonce=`getHeaderByKeyword "$uploadResponse" "EagleId"`
picPath=`getContentByKeyword "$uploadResponse" "key"`
echo $nonce >> test2.txt
echo $picPath >> test2.txt
nonce=${nonce:8:10}
timeStamp=`date +%s`   
nonce=timeStamp
echo $nonce >> test2.txt

Step one: fetch the token via get_upload_token, using the user’s uid/sid. Step two: POST to upload.qbox.me to upload the local GIF — -F "file=@/Users/mac/Desktop/test4.gif" above. On success you get back something like this, where key is the image URL you’ll splice into headimgurl:

{
  "key" : "sa/fc6861905d20200613100399.gif",
  "hash" : "FifEYajsNl7aqU88gsOYSyewk199"
}

Disassembling to find the method to inject

Decrypt the app first (details in my bfinject writeup). Open the Mach-O in Hopper Disassembler and search for the avatar-update API keyword update_user_info:

Right-click to find who calls this address, then jump to the pseudocode:

It’s basically this method: userApiUpdateUserInfoWithDict — it updates user info from a dictionary, and headimgurl is one of the dictionary keys. Change that value and the image URL changes. That’s the hypothesis, anyway.

Writing the tweak

To verify it, I wrote a tweak that hooks the method and prints the argument:

%hook SnakeAPI
+ (void)userApiUpdateUserInfoWithDict:(id)arg1 success:(id)arg2 failure:(id)arg3 {
	NSLog(@"arg before: %@", arg1);
 	%log;
	%orig; 
}
%end

Build with nic.pl (template 11 for a tweak), set the MobileSubstrate Bundle filter to the app’s bundle ID, then add the arch and target lines to the Makefile:

ARCHS = armv7 arm64
TARGET = iphone:latest:8.0

Run make package to produce a deb, install it on the phone (via SSH, a phone-assistant tool, or Filza), and trigger an avatar upload while watching the macOS console:

[headimgurl] = 'sa/234234234234234.jpg'

So arg1 is a dictionary — replacing its headimgurl changes the avatar. Since arg1 is immutable, I just nuked it and built a fresh dictionary. The final tweak:

%hook SnakeAPI
+ (void)userApiUpdateUserInfoWithDict:(id)arg1 success:(id)arg2 failure:(id)arg3 {
	NSLog(@"before: %@", arg1);
	arg1 =nil;
	arg1 = [NSDictionary dictionaryWithObjectsAndKeys:
    	@"https://example/sa/2345345345345345.jpg", @"headimgurl",nil];
	NSLog(@"after: %@", arg1);
 	%log;
  	%orig; 
}
%end

The image URL is hardcoded — it’s the address returned by my shell upload, an animated GIF. Rebuild, reinstall, upload an avatar. The upload succeeds but the avatar still shows the old image — local cache, I figured. Restart the game, and there it is: the avatar refreshes into my animated GIF.


Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch