Method and code implementation for detecting Android virtual machine

Method and code implementation for detecting Android virtual machine

I just read some open source projects/articles/papers about Detect Android Emulator. The ones I read are actually methods proposed in 2013 and 2014. Most of them detect some environmental properties and check some files, but in fact, the detection ideas are not limited to this. Some are very direct to detect qemu, while other methods are indirect, such as detecting adb, detecting ptrace, etc. The ideas are also very flexible.

*** I have seen some suggestions to detect by using the actual differences between simulated CPUs such as QEMU and physical CPUs (task scheduling differences), the differences between simulated sensors and physical sensors, cache differences, etc. Compared with detecting environmental properties, the detection effect will be much better.

[[228598]]

Below I will list some methods/ideas/codes proposed in various materials for everyone to communicate and learn.

QEMU Properties

  1. public class Property {
  2. public String name ;
  3. public String seek_value;
  4.  
  5. public Property(String name , String seek_value) {
  6. this.name = name ;
  7. this.seek_value = seek_value;
  8. }
  9. }
  10. /**
  11. * Known attributes, in the format of [attribute name, attribute value], used to determine whether the current environment is QEMU
  12. */
  13. private static Property[] known_props = {new Property( "init.svc.qemud" , null ),
  14. new Property( "init.svc.qemu-props" , null ), new Property( "qemu.hw.mainkeys" , null ),
  15. new Property( "qemu.sf.fake_camera" , null ), new Property( "qemu.sf.lcd_density" , null ),
  16. new Property( "ro.bootloader" , "unknown" ), new Property( "ro.bootmode" , "unknown" ),
  17. new Property( "ro.hardware" , "goldfish" ), new Property( "ro.kernel.android.qemud" , null ),
  18. new Property( "ro.kernel.qemu.gles" , null ), new Property( "ro.kernel.qemu" , "1" ),
  19. new Property( "ro.product.device" , "generic" ), new Property( "ro.product.model" , "sdk" ),
  20. new Property( "ro.product.name" , "sdk" ),
  21. new Property( "ro.serialno" , null )};
  22. /**
  23. * A threshold, because the so-called "known" simulator properties are not completely accurate, there may be false positive results, so maintaining a certain threshold can make the detection effect better
  24. */
  25. private static   int MIN_PROPERTIES_THRESHOLD = 0x5;
  26. /**
  27. * Try to detect the QEMU environment by querying the specified system properties, and then compare the detection results with the threshold.
  28. *
  29. * @param context A {link Context} object for the Android application.
  30. * @ return {@code true } if enough properties where found to exist or {@code false } if not .
  31. */
  32. public boolean hasQEmuProps(Context context) {
  33. int found_props = 0;
  34.  
  35. for (Property property : known_props) {
  36. String property_value = Utilities.getProp(context, property. name );
  37. // See if we expected just a non- null  
  38. if ((property.seek_value == null ) && (property_value != null )) {
  39. found_props++;
  40. }
  41. // See if we expected a value to seek
  42. if ((property.seek_value != null ) && (property_value.indexOf(property.seek_value) != -1)) {
  43. found_props++;
  44. }
  45.  
  46. }
  47.  
  48. if (found_props >= MIN_PROPERTIES_THRESHOLD) {
  49. return   true ;
  50. }
  51.  
  52. return   false ;
  53. }

These are attributes that are compared based on some experience and features. I will not explain the attributes here and some of the subsequent file attributes in detail.

Device ID

  1. private static String[] known_device_ids = { "000000000000000" , // Default emulator id
  2. "e21833235b6eef10" , // VirusTotal id
  3. "012345678912345" };
  4. public   static boolean hasKnownDeviceId(Context context) {
  5. TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
  6.  
  7. String deviceId = telephonyManager.getDeviceId();
  8.  
  9. for (String known_deviceId : known_device_ids) {
  10. if (known_deviceId.equalsIgnoreCase(deviceId)) {
  11. return   true ;
  12. }
  13.  
  14. }
  15. return   false ;
  16. }

Default Number

  1. private static String[] known_numbers = {
  2. "15555215554" , // emulator default phone number + VirusTotal
  3. "15555215556" , "15555215558" , "15555215560" , "15555215562" , "15555215564" , "15555215566" ,
  4. "15555215568" , "15555215570" , "15555215572" , "15555215574" , "15555215576" , "15555215578" ,
  5. "15555215580" , "15555215582" , "15555215584" ,};
  6. public   static boolean hasKnownPhoneNumber(Context context) {
  7. TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
  8.  
  9. String phoneNumber = telephonyManager.getLine1Number();
  10.  
  11. for (String number : known_numbers) {
  12. if (number.equalsIgnoreCase(phoneNumber)) {
  13. return   true ;
  14. }
  15.  
  16. }
  17. return   false ;
  18. }

IMSI

  1. private static String[] known_imsi_ids = { "310260000000000" // Default IMSI number
  2. };
  3. public   static boolean hasKnownImsi(Context context) {
  4. TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
  5. String imsi = telephonyManager.getSubscriberId();
  6.  
  7. for (String known_imsi : known_imsi_ids) {
  8. if (known_imsi.equalsIgnoreCase(imsi)) {
  9. return   true ;
  10. }
  11. }
  12. return   false ;
  13. }

Build Class

  1. public   static boolean hasEmulatorBuild(Context context) {
  2. String BOARD = android.os.Build.BOARD; // The name   of the underlying board, like   "unknown" .
  3. // This appears to occur often on   real hardware... that's sad
  4. // String BOOTLOADER = android.os.Build.BOOTLOADER; // The system bootloader version number.
  5. String BRAND = android.os.Build.BRAND; // The brand (eg, carrier) the software is customized for , if any .
  6. // "generic"  
  7. String DEVICE = android.os.Build.DEVICE; // The name   of the industrial design. "generic"  
  8. String HARDWARE = ​​android.os.Build.HARDWARE; // The name   of the hardware ( from the kernel command line or  
  9. // /proc). "goldfish"  
  10. String MODEL = android.os.Build.MODEL; // The end - user -visible name   for the end product. "sdk"  
  11. String PRODUCT = android.os.Build.PRODUCT; // The name   of the overall product.
  12. if ((BOARD.compareTo( "unknown" ) == 0) /* || (BOOTLOADER.compareTo( "unknown" ) == 0) */
  13. || (BRAND.compareTo( "generic" ) == 0) || (DEVICE.compareTo( "generic" ) == 0)
  14. || (MODEL.compareTo( "sdk" ) == 0) || (PRODUCT.compareTo( "sdk" ) == 0)
  15. || (HARDWARE.compareTo( "goldfish" ) == 0)) {
  16. return   true ;
  17. }
  18. return   false ;
  19. }

Operator Name

  1. public   static boolean isOperatorNameAndroid(Context paramContext) {
  2. String szOperatorName = ((TelephonyManager) paramContext.getSystemService(Context.TELEPHONY_SERVICE)).getNetworkOperatorName();
  3. boolean isAndroid = szOperatorName.equalsIgnoreCase( "android" );
  4. return isAndroid;
  5. }

QEMU Driver

  1. private static String[] known_qemu_drivers = { "goldfish" };
  2. /**
  3. * Read the driver file and check whether it contains known qemu drivers
  4. *
  5. * @ return {@code true } if any known drivers where found to exist or {@code false } if not .
  6. */
  7. public   static boolean hasQEmuDrivers() {
  8. for (File drivers_file : new File[]{new File( "/proc/tty/drivers" ), new File( "/proc/cpuinfo" )}) {
  9. if (drivers_file.exists() && drivers_file.canRead()) {
  10. // We don't care to   read much past things since info we care about should be inside here
  11. byte[] data = new byte[1024];
  12. try {
  13. InputStream is = new FileInputStream(drivers_file);
  14. is . read ( data );
  15. is . close ();
  16. } catch (Exception exception) {
  17. exception.printStackTrace();
  18. }
  19.  
  20. String driver_data = new String(data);
  21. for (String known_qemu_driver : FindEmulator.known_qemu_drivers) {
  22. if (driver_data.indexOf(known_qemu_driver) != -1) {
  23. return   true ;
  24. }
  25. }
  26. }
  27. }
  28.  
  29. return   false ;
  30. }

QEMU Files

  1. private static String[] known_files = { "/system/lib/libc_malloc_debug_qemu.so" , "/sys/qemu_trace" ,
  2. "/system/bin/qemu-props" };
  3. /**
  4. * Check if known QEMU environment files exist
  5. *
  6. * @ return {@code true } if any files where found to exist or {@code false } if not .
  7. */
  8. public   static boolean hasQEmuFiles() {
  9. for (String pipe : known_files) {
  10. File qemu_file = new File(pipe);
  11. if (qemu_file.exists()) {
  12. return   true ;
  13. }
  14. }
  15.  
  16. return   false ;
  17. }

Genymotion Files

  1. private static String[] known_geny_files = { "/dev/socket/genyd" , "/dev/socket/baseband_genyd" };
  2. /**
  3. * Check if there is a known Genemytion environment file
  4. *
  5. * @ return {@code true } if any files where found to exist or {@code false } if not .
  6. */
  7. public   static boolean hasGenyFiles() {
  8. for (String file : known_geny_files) {
  9. File geny_file = new File(file);
  10. if (geny_file.exists()) {
  11. return   true ;
  12. }
  13. }
  14.  
  15. return   false ;
  16. }

QEMU pipes

  1. private static String[] known_pipes = { "/dev/socket/qemud" , "/dev/qemu_pipe" };
  2. /**
  3. * Check if there are any known pipes used by QEMU
  4. *
  5. * @ return {@code true } if any pipes where found to exist or {@code false } if not .
  6. */
  7. public   static boolean hasPipes() {
  8. for (String pipe : known_pipes) {
  9. File qemu_socket = new File(pipe);
  10. if (qemu_socket.exists()) {
  11. return   true ;
  12. }
  13. }
  14.  
  15. return   false ;
  16. }

Setting breakpoints

  1. static {
  2. // This is   only valid for arm
  3. System.loadLibrary( "anti" );
  4. }
  5. public native static   int qemuBkpt();
  6.  
  7. public   static boolean checkQemuBreakpoint() {
  8. boolean hit_breakpoint = false ;
  9.  
  10. // Potentially you may want to see if this is a specific value
  11. int result = qemuBkpt();
  12.  
  13. if (result > 0) {
  14. hit_breakpoint = true ;
  15. }
  16.  
  17. return hit_breakpoint;
  18. }

The following is the corresponding C++ code

  1. void handler_sigtrap( int signo) {
  2. exit(-1);
  3. }
  4.  
  5. void handler_sigbus( int signo) {
  6. exit(-1);
  7. }
  8.  
  9. int setupSigTrap() {
  10. // BKPT throws SIGTRAP on nexus 5 / oneplus one ( and most devices)
  11. signal(SIGTRAP, handler_sigtrap);
  12. // BKPT throws SIGBUS on nexus 4
  13. signal(SIGBUS, handler_sigbus);
  14. }
  15.  
  16. // This will cause a SIGSEGV on   some QEMU or be properly respected
  17. int tryBKPT() {
  18. __asm__ __volatile__ ( "bkpt 255" );
  19. }
  20.  
  21. jint Java_diff_strazzere_anti_emulator_FindEmulator_qemuBkpt(JNIEnv* env, jobject jObject) {
  22.  
  23. pid_t child = fork();
  24. int child_status, status = 0;
  25.  
  26. if(child == 0) {
  27. setupSigTrap();
  28. tryBKPT();
  29. } else if(child == -1) {
  30. status = -1;
  31. } else {
  32.  
  33. int timeout = 0;
  34. int i = 0;
  35. while ( waitpid(child, &child_status, WNOHANG) == 0 ) {
  36. sleep(1);
  37. // Time could be adjusted here, though in my experience if the child has not returned instantly
  38. // then something has gone wrong and it is an emulated device
  39. if(i++ == 1) {
  40. timeout = 1;
  41. break;
  42. }
  43. }
  44.  
  45. if(timeout == 1) {
  46. // Process timed out - likely an emulated device and child is frozen
  47. status = 1;
  48. }
  49.  
  50. if ( WIFEXITED(child_status) ) {
  51. // The child process exits normally
  52. status = 0;
  53. } else {
  54. // Didn't exit properly - very likely an emulator
  55. status = 2;
  56. }
  57.  
  58. // Ensure child is dead
  59. kill(child, SIGKILL);
  60. }
  61.  
  62. return status;
  63. }

My description here may not be accurate, because I have not found relevant information. I can only explain it based on my own understanding:

SIGTRAP is a signal generated when the debugger sets a breakpoint. It can be triggered on most phones such as nexus5 or OnePlus phones. SIGBUS is a bus error. The pointer may access a valid address, but the bus cannot be used due to data misalignment and other reasons. It can be triggered on nexus4 phones. Bkpt is the breakpoint instruction of arm. This is an issue that qemu has raised. qemu will crash due to the SIGSEGV signal. The author wants to use this crash to detect qemu. If the program does not exit normally or is frozen, it can be determined that it is likely in the emulator.

ADB

  1. public   static boolean hasEmulatorAdb() {
  2. try {
  3. return FindDebugger.hasAdbInEmulator();
  4. } catch (Exception exception) {
  5. exception.printStackTrace();
  6. return   false ;
  7. }
  8. }

isUserAMonkey()

  1. public   static boolean hasEmulatorAdb() {
  2. try {
  3. return FindDebugger.hasAdbInEmulator();
  4. } catch (Exception exception) {
  5. exception.printStackTrace();
  6. return   false ;
  7. }
  8. }

This is actually used to detect whether the current operation is requested by the user or the script.

isDebuggerConnected()

  1. /**
  2. * Believe it or not, there are many hardening programs that use this method...
  3. */
  4. public   static boolean isBeingDebugged() {
  5. return Debug.isDebuggerConnected();
  6. }

This method is used to detect debugging and determine whether a debugger is connected.

ptrace

  1. private static String tracerpid = "TracerPid" ;
  2. /**
  3. * Alibaba uses this to detect whether the application process is being tracked
  4. *
  5. * Easy to circumvent, the usage is to create a thread to check every 3 seconds, if detected, the program will crash
  6. *
  7. * @return  
  8. * @throws IOException
  9. */
  10. public   static boolean hasTracerPid() throws IOException {
  11. BufferedReader reader = null ;
  12. try {
  13. reader = new BufferedReader(new InputStreamReader(new FileInputStream( "/proc/self/status" )), 1000);
  14. String line;
  15.  
  16. while ((line = reader.readLine()) != null ) {
  17. if (line.length() > tracerpid.length()) {
  18. if ( line.substring (0, tracerpid.length()).equalsIgnoreCase(tracerpid)) {
  19. if ( Integer .decode(line.substring ( tracerpid.length() + 1).trim()) > 0) {
  20. return   true ;
  21. }
  22. break;
  23. }
  24. }
  25. }
  26.  
  27. } catch (Exception exception) {
  28. exception.printStackTrace();
  29. finally
  30. reader.close () ;
  31. }
  32. return   false ;
  33. }

This method is to check the TracerPid item in /proc/self/status. This item defaults to 0 when there is no tracing, and will be modified to the corresponding pid when a program is tracing. Therefore, if TracerPid is not equal to 0, it can be considered to be in the simulator environment.

TCP Connection

  1. public   static boolean hasAdbInEmulator() throws IOException {
  2. boolean adbInEmulator = false ;
  3. BufferedReader reader = null ;
  4. try {
  5. reader = new BufferedReader(new InputStreamReader(new FileInputStream( "/proc/net/tcp" )), 1000);
  6. String line;
  7. // Skip column names
  8. reader.readLine();
  9.  
  10. ArrayList<tcp> tcpList = new ArrayList<tcp>();
  11.  
  12. while ((line = reader.readLine()) != null ) {
  13. tcpList. add (tcp. create (line.split( "\\W+" )));
  14. }
  15.  
  16. reader.close () ;
  17.  
  18. // Adb is always bounce to 0.0.0.0 - though the port can change
  19. // real devices should be != 127.0.0.1
  20. int adbPort = -1;
  21. for (tcp tcpItem : tcpList) {
  22. if (tcpItem.localIp == 0) {
  23. adbPort = tcpItem.localPort;
  24. break;
  25. }
  26. }
  27.  
  28. if (adbPort != -1) {
  29. for (tcp tcpItem : tcpList) {
  30. if ((tcpItem.localIp != 0) && (tcpItem.localPort == adbPort)) {
  31. adbInEmulator = true ;
  32. }
  33. }
  34. }
  35. } catch (Exception exception) {
  36. exception.printStackTrace();
  37. finally
  38. reader.close () ;
  39. }
  40.  
  41. return adbInEmulator;
  42. }
  43.  
  44. public   static class tcp {
  45.  
  46. public   int id;
  47. public long localIp;
  48. public   int localPort;
  49. public   int remoteIp;
  50. public   int remotePort;
  51.  
  52. static tcp create (String[] params) {
  53. return new tcp(params[1], params[2], params[3], params[4], params[5], params[6], params[7], params[8],
  54. params[9], params[10], params[11], params[12], params[13], params[14]);
  55. }
  56.  
  57. public tcp(String id, String localIp, String localPort, String remoteIp, String remotePort, String state,
  58. String tx_queue, String rx_queue, String tr, String tm_when, String retrnsmt, String uid,
  59. String timeout, String inode) {
  60. this.id = Integer .parseInt(id, 16);
  61. this.localIp = Long.parseLong(localIp, 16);
  62. this.localPort = Integer .parseInt(localPort, 16);
  63. }
  64. }

This method is to determine whether adb exists by reading the information of /proc/net/tcp. For example, the information of the real machine is 0: 4604D20A:B512 A3D13AD8..., and the corresponding information on the simulator is 0: 00000000:0016 00000000:0000, because adb is usually reflected to the IP address 0.0.0.0. Although the port may change, it is indeed feasible.

TaintDroid

  1. public   static boolean hasPackageNameInstalled(Context context, String packageName) {
  2. PackageManager packageManager = context.getPackageManager();
  3.  
  4. // In theory, if the package installer does not throw an exception, package exists
  5. try {
  6. packageManager.getInstallerPackageName(packageName);
  7. return   true ;
  8. } catch (IllegalArgumentException exception) {
  9. return   false ;
  10. }
  11. }
  12. public   static boolean hasAppAnalysisPackage(Context context) {
  13. return Utilities.hasPackageNameInstalled(context, "org.appanalysis" );
  14. }
  15. public   static boolean hasTaintClass() {
  16. try {
  17. Class.forName( "dalvik.system.Taint" );
  18. return   true ;
  19. }
  20. catch (ClassNotFoundException exception) {
  21. return   false ;
  22. }
  23. }

This is relatively simple. It is to determine whether the TaintDroid taint analysis tool is installed by detecting the package name and the Taint class. In addition, some member variables of TaintDroid can also be detected.

eth0

  1. private static boolean hasEth0Interface() {
  2. try {
  3. for (Enumeration<NetworkInterface> en = NetworkInterface.getNetworkInterfaces(); en.hasMoreElements(); ) {
  4. NetworkInterface intf = en.nextElement();
  5. if (intf.getName().equals( "eth0" ))
  6. return   true ;
  7. }
  8. } catch (SocketException ex) {
  9. }
  10. return   false ;
  11. }

Check whether the eth0 network card exists.

sensor

Mobile phones are equipped with a variety of sensors, but they essentially output values ​​based on information collected from the environment, so it is very challenging to simulate sensors. These sensors provide new opportunities for identifying mobile phones and simulators.

For example, in the paper Rage Against the Virtual Machine: Hindering Dynamic Analysis of Android Malware, the authors tested the accelerometer of the Android emulator and found that the sensors on the Android emulator would produce the same value at the same time interval (the observed result was 0.8s, with a standard deviation of 0.003043). Obviously, this is impossible for real-world sensors.

So we can first register a sensor listener. If the registration fails, it may be in the simulator (excluding the possibility that the actual device does not support the sensor). If the registration is successful, then check the onSensorChanged callback method. If the sensor value or time interval observed in the process of calling this method continuously is the same, then it can be determined that it is in the simulator environment.

QEMU Task Scheduling

For performance reasons, QEMU does not actively update the program counter (PC) every time it executes an instruction. Since the translated instructions are executed locally, increasing the PC requires additional instructions and incurs overhead. Therefore, QEMU only updates the program counter when executing instructions that interrupt the linear execution process (such as branch instructions).

This means that if a scheduling event occurs during the execution of some basic blocks, there is no way to restore the PC before scheduling. For this reason, QEMU only schedules events after executing basic blocks, and never during execution.

As shown in the figure above, because scheduling can occur at any time, a large number of scheduling points will be observed in a non-simulator environment. In a simulator environment, only specific scheduling points can be seen.

SMC Identification

Because QEMU tracks code page changes, there is a novel way to instrument QEMU using Self-Modifying Code (SMC) to cause execution flow changes between the emulator and the real device.

ARM processors contain two different caches, one for instruction access (I-Cache) and the other for data access (D-Cache). But Harvard architectures like ARM do not guarantee consistency between I-Cache and D-Cache. Therefore, it is possible for the CPU to execute an old (perhaps invalid) piece of code after a new piece of code has been written to the main memory.

This problem can be solved by forcing the two caches to be consistent, which has two steps:

  1. Clean up the main memory so that the newly written code in the D-Cache can be moved into the main memory
  2. Invalidate the I-Cache so that it can be refilled with new contents from main memory

In native Android code, you can use the cacheflush function, which completes the above operation through system calls.

Identify the code, using a memory with read-write permissions, which contains the code of two different functions f1 and f2. These two functions are actually very simple. They simply append their respective function names to the end of a global string variable. These two functions will be interleaved in the loop, so that the function call sequence can be inferred from the resulting string.

As mentioned earlier, we call cacheflush to synchronize the cache. Running the code on a real device and the simulator gives the same results - each execution produces a consistent sequence of function calls.

Next, we remove the call to cacheflush and perform the same operation. In the actual device, we observe a random sequence of function calls each time we run it. This is also because the I-Cache may contain some old instructions, and the cache is out of sync each time it is called, as mentioned above.

This does not happen in the simulator environment, and the function call sequence will be exactly the same as before cacheflush was removed, that is, the cache is consistent before each function call. This is because QEMU tracks modifications on code pages and ensures that the generated code always matches the target instructions in memory, so QEMU abandons the previous version of the code translation and regenerates new code.

Conclusion

You may think there are enough detection methods here, but I have only read the data from 2013 and 2014. The data from recent years has not been included.

***I will integrate these detection methods into a mind map (see attachment) for everyone to see, welcome to communicate with me and guide me

Reference Links

strazzere/anti-emulator: ***Published at HitCon in 2013, it proposed some methods and ideas for detecting virtual machines. It should be the pioneering work of Android emulator detection. This article is mainly based on this repository.

Rage Against the Virtual Machine: Hindering Dynamic Analysis of Android Malware: Detection through task scheduling and identification using SMC are both referenced from this paper. This paper and the following paper are very valuable for reference and are worth reading.

Evading Android Runtime Analysis via Sandbox Detection: This paper proposes a lot of methods and ideas for detecting the Android runtime environment. It is rich in content and very comprehensive, and it is also worth reading.

CalebFenton/AndroidEmulatorDetect: This repository actually integrates the detection methods and codes in some articles and repositories, and it is not comprehensive, but it does provide a lot of reference links, and I followed the clues.

How can I detect when an Android application is running in the emulator? Netizens have given many solutions. But in fact, they are not comprehensive, and are just the tip of the iceberg in emulator detection. After all, there are so many places that can be detected.

Using the Task Scheduling Feature to Detect Android Emulators

<<:  Campus recruitment strategy: 43 high-quality interview experiences (iOS development)

>>:  iOS 11 installation rate is close to saturation, while Android 8.0 is stagnant

Recommend

The data and effects of major mobile game launches are all here!

Mobile game traffic has three main sources: chann...

A guide to entering and starting broadcasting on Douyin Box!

My friends, will you use Douyin box instead of Ta...

Growth Hacking in Operations: Explosive Marketing?

Let’s talk about growth hacking today. Growth hac...

Didi Autonomous Driving Receives Investment from GAC Group

On October 12, Didi Autonomous Driving announced ...

Will a “wandering black hole” hit the Earth?

A "wandering" supermassive black hole w...

What player can be used to turn files into short videos?

What kind of video player should be installed if ...

Learn about the North and South Pole

1. Polar Overview Polar refers to the South Pole ...

Analysis of 5 different cross-border e-commerce operation models

This article analyzes different cross-border e-co...

Foreign experts: "We left, but you still have Wang Ganchang"

Creative team: China Science and Technology Museu...

Zbrush course Guyue next generation game props production [good quality]

Zbrush course Guyue next generation game props pr...

3 key points for planning and promoting popular events!

Is event planning difficult? In the author's ...