5 minute read

Introduction

As most of you know, ShooterGame is one of the most popular official Unreal Engine sample projects employed as a base in many well-known games: PUBG, Lawbreakers, ARK Survival Evolved, Hell Let Loose, Valorant…

Using a consolidated sample project as a base can shorten months of development, as many of the features included in the sample are needed as part of the vertical slice of many of these projects, so it makes sense for companies to explore the solutions offered by the engine developers, in this case Epic. Because who is going to be better at developing a game made in Unreal Engine than Epic itself?

I don’t doubt that, but we, developers, are human, and we are by no means perfect machines capable to detect every single use case which would make a sample solid enough to be production ready. So oopsies can definitely happen.

In this post we’ll learn about the major vulnerability of ShooterGame that spread across many projects using it as a base. This vulnerability was first exposed by Jambax in Unreal Engine’s Slackers discord server, so please, go visit his wonderful gamedev blog!!

Server side validation

ShooterGame provides a function to confirm whether a linetrace shoot is valid or not. This has to be done as the linetrace shooting in ShooterGame is performed purely in the client (for a 0 lag shooting experience), and then validated in the server in order to proceed with the damage calculation.

This technique is employed in many games and follows the principle of “trust and verify”, in which we trust the client about the incoming shooting data, which then we validate.

Following next, we display ShooterGame’s server side shooting validation:

void AShooterWeapon_Instant::ServerNotifyHit_Implementation(const FHitResult& Impact, FVector_NetQuantizeNormal ShootDir, int32 RandomSeed, float ReticleSpread)
{
	const float WeaponAngleDot = FMath::Abs(FMath::Sin(ReticleSpread * PI / 180.f));

	// if we have an instigator, calculate dot between the view and the shot
	if (GetInstigator() && (Impact.GetActor() || Impact.bBlockingHit))
	{
		const FVector Origin = GetMuzzleLocation();
		const FVector ViewDir = (Impact.Location - Origin).GetSafeNormal();

		// is the angle between the hit and the view within allowed limits (limit + weapon max angle)
		const float ViewDotHitDir = FVector::DotProduct(GetInstigator()->GetViewRotation().Vector(), ViewDir);
		if (ViewDotHitDir > InstantConfig.AllowedViewDotHitDir - WeaponAngleDot)
		{
			if (CurrentState != EWeaponState::Idle)
			{
				if (Impact.GetActor() == NULL)
				{
					if (Impact.bBlockingHit)
					{
						ProcessInstantHit_Confirmed(Impact, Origin, ShootDir, RandomSeed, ReticleSpread);
					}
				}
				// assume it told the truth about static things because the don't move and the hit 
				// usually doesn't have significant gameplay implications
				else if (Impact.GetActor()->IsRootComponentStatic() || Impact.GetActor()->IsRootComponentStationary())
				{
					ProcessInstantHit_Confirmed(Impact, Origin, ShootDir, RandomSeed, ReticleSpread);
				}
				else
				{
					// Get the component bounding box
					const FBox HitBox = Impact.GetActor()->GetComponentsBoundingBox();

					// calculate the box extent, and increase by a leeway
					FVector BoxExtent = 0.5 * (HitBox.Max - HitBox.Min);
					BoxExtent *= InstantConfig.ClientSideHitLeeway;

					// avoid precision errors with really thin objects
					BoxExtent.X = FMath::Max(20.0f, BoxExtent.X);
					BoxExtent.Y = FMath::Max(20.0f, BoxExtent.Y);
					BoxExtent.Z = FMath::Max(20.0f, BoxExtent.Z);

					// Get the box center
					const FVector BoxCenter = (HitBox.Min + HitBox.Max) * 0.5;

					// if we are within client tolerance
					if (FMath::Abs(Impact.Location.Z - BoxCenter.Z) < BoxExtent.Z &&
						FMath::Abs(Impact.Location.X - BoxCenter.X) < BoxExtent.X &&
						FMath::Abs(Impact.Location.Y - BoxCenter.Y) < BoxExtent.Y)
					{
						ProcessInstantHit_Confirmed(Impact, Origin, ShootDir, RandomSeed, ReticleSpread);
					}
					else
					{
						UE_LOG(LogShooterWeapon, Log, TEXT("%s Rejected client side hit of %s (outside bounding box tolerance)"), *GetNameSafe(this), *GetNameSafe(Impact.GetActor()));
					}
				}
			}
		}
		else if (ViewDotHitDir <= InstantConfig.AllowedViewDotHitDir)
		{
			UE_LOG(LogShooterWeapon, Log, TEXT("%s Rejected client side hit of %s (facing too far from the hit direction)"), *GetNameSafe(this), *GetNameSafe(Impact.GetActor()));
		}
		else
		{
			UE_LOG(LogShooterWeapon, Log, TEXT("%s Rejected client side hit of %s"), *GetNameSafe(this), *GetNameSafe(Impact.GetActor()));
		}
	}
}

As we can see, this function validates the shooting angle alongside a simple bounding box calculation, which is great, as we are preventing shooting in non-allowed directions within a thresholded tolerance, which should fall within a box.

The problem

With the function above, we are mitigating a number of cases in which a cheater could take advantage of our game, but we are forgetting something super important: Our configuration parameters should be clamped when needed. In this case, the problem resides in the InstantConfig.ClientSideHitLeeway variable, which by default, it is configured to scale the target actor bounding box by a factor of 200.

The applied scale validates all the hits that fall within the scaled area. And in this case a scale of 200 covers the whole map.

So… we can do stuff like this:

Teleport hack

Yep, we just teleported a random player in front of us on our local client and killed them. This is because the preconfigured scale is giant for our use case.

So any malicious user could take this and use it to their advantage.

How does it work?

Essentially, we can teleport players in our local client by doing memory manipulation (ie: Cheat Engine). The server position of the pawns will remain unaffected however, we’ll get to see enemy pawns in the desired position since we are manipulating their location on our computer.

For the sake of the example I created this little blueprint that emulates said functionality:

Teleport hack logic

Fortunately, modern anti cheat systems (ie: EAC) prevent software memory manipulation, but it’s been demonstrated over the years that escaping anti cheat systems is possible. That’s why our server-side validation code should be also robust.

The solution

After showing the problem, I thought of a way to solve it in the shortest possible time. Since security vulnerabilities should be mitigated as quickly as possible while thinking of a more elaborate solution that would tremendously improve the problem.

In this case my fast solution involved clamping the scale value onto something reasonable, in this case I decided to do it directly in runtime (but feel free to use meta-property clamping for a better user experience):

...
	// calculate the box extent, and increase by a leeway
	FVector BoxExtent = 0.5 * (HitBox.Max - HitBox.Min);
	BoxExtent *= FMath::Min(2.0f, InstantConfig.ClientSideHitLeeway);
...

As we can see, our simple and not involved solution concerns adding runtime safety over the configured leeway. With this fix, when we try to kill teleported pawns, we will fail as the server will reject the shoot:

Teleport hack hotfixed

This isn’t the best solution for the problem but it’s good enough to mitigate partially the teleport issue while we buy some time to build a proper and more involved solution (rewinding the victim’s capsule and checking against it).

Conclusion

I hope this little anecdote will make you think about the implications and importance of security in online games for an enjoyable gameplay experience for everyone.

In addition, in game development it is important to know how to mitigate problems temporarily until a more robust solution is found, especially if we are in production. Obviously all this under a controlled environment while providing functional code (test your stuff!!).

Finally I’d like to encourage you to support me on twitter with a follow so more people can get to read my little tales about net programming and game development in general!

Thanks for reading, vori.